Skip to content

20 October 2026

Forty-one entries today:

  • The sso and registration headings, and two second-factor messages, in the site's language
  • A page that prints the time is cached again
  • A migration reaches the long-running workers
  • prepareQuery() on PostgreSQL reads a DATE_FORMAT pattern written for printf
  • A schema is no longer glued to the word before a backticked name on PostgreSQL
  • The query builder quotes a qualified mixed-case column on PostgreSQL
  • REGEXP, RLIKE and NOT REGEXP on PostgreSQL
  • A PHP bool reaches a smallint flag on PostgreSQL
  • User::save() writes active on PostgreSQL
  • An MCP client sees the tools again
  • Widening applications.callback no longer stops at a materialized usage_statistics
  • create_oauth2_application_grants_table replaces a legacy view of another shape
  • Model::_load() throws on PostgreSQL when its query fails
  • CONCAT() with a NULL is NULL on PostgreSQL too
  • applications.oauth2_active_tokens is one row per token again on PostgreSQL; the summary is …_summary
  • db:migrate-from-mysql makes a char(n) a varchar(n)
  • DELETE … LIMIT and UPDATE … LIMIT through query() on PostgreSQL
  • Clearing a cache whose prefix holds a : clears it
  • When the password last changed, and a way back from the change-password page
  • A webhook endpoint's signing secret can be shown again, and is never empty
  • Every built-in webhook type fires, and only to the user's own applications
  • An application can leave the framework's help pages out
  • whereLike(): text search that ignores case and accents on every database
  • mail:flush waits for mail queued for later
  • The daemon orchestrator stops its daemons before it exits, and its job name is its own
  • Queue handlers from app.php, and a worker that takes only what it can run
  • Scheduled commands in process, and the queue's retention as configuration
  • Alerts in the Tailwind views read as one block
  • The queue screen is a DataTable, with stop, duplicate and an application's own columns
  • mail:flush takes an application's own checks, counts and switch
  • The Roadmap notes a dashboard test that sometimes skips itself
  • Queued mail keeps its names, and a refusal for now waits before the next attempt
  • A notification reaches an open page at once
  • The queue screen shows what the queue is doing, each task, and its last two days
  • The queue screen's time columns say they are times
  • Email history: failed reads Failed, and an application's statuses, columns and resend
  • Http\Client::json() throws on data it cannot encode
  • broadcast:serve reads app.php and reaches every user's notifications
  • The orchestrator waits out its whole shutdown grace
  • An admin shell's breadcrumb is the only one, and the email preview stops throwing
  • The queue's task and statistics pages leave the breadcrumb to the shell too

The sso and registration headings, and two second-factor messages, in the site's language

Fixed

  • The sso and registration pages had English headings on a Greek site. Account handed their views 'Single Sign-On' and 'Create Account' as literals, and the views print $this->header as given. The two success messages TwoFactorAuth hands its views ("New backup codes generated…", "Two-factor authentication has been enabled successfully!") were English the same way. All four go through t(), and the Greek catalogue has them.

A page that prints the time is cached again

Fixed

  • Yesterday's check for the visitor's id in a stored page refused every page that printed time(). It looked for the value of each tracking cookie the response set, and lastseen holds time(). So a page whose script printed the current time, or a cache-buster, matched it within the same second, and was never stored for a visitor without cookies: every bot and every first visit. One application lost the cache on its second page by search traffic. A value of digits only is no longer looked for. A timestamp identifies nobody; the visitor id is hexadecimal.

A migration reaches the long-running workers

A migration run without a deploy, or after the deploy's restart (the request that runs pending migrations comes later), left every worker on the old schema: the tables hasTable() had found, its models' column lists, its prepared statements. The orchestrator restarted daemons on a new commit, and a migration changes none.

Added

  • MigrationRunner::schemaGeneration(): an opaque token in var/schema-generation that moves whenever a batch applies or rolls back anything, and not when nothing was pending.
  • DaemonOrchestrator compares it on the same timer as the git hash and restarts its daemons when it moves, printing [schema] Migrations changed the schema.
  • CommandBase::shouldStop(), asked between tasks by every worker loop, forgets the tables and columns it found when the generation has moved.

Fixed

  • A table dropped by another process stayed "found". A query that fails because the table is not there (MySQL 1146, PostgreSQL 42P01), raw or through the builder, makes the connection forget what hasTable() and hasColumn() remembered. The process is wrong once and right from then on.

prepareQuery() on PostgreSQL reads a DATE_FORMAT pattern written for printf

Fixed

  • prepareQuery("SELECT DATE_FORMAT(`created`, '%%Y-%%m') … WHERE `email` = %s", $email) threw ValueError: Unknown format specifier "Y" on PostgreSQL. The MySQL translation ran before printf, and read %%Y as MySQL would: a literal percent, then a Y. It kept that as text, and printf then met %Y. prepareQuery() now has the pattern read with %% as %, and the to_char pattern that comes out keeps any literal percent doubled for printf. The same call gives to_char(…, 'YYYY"-"MM') and the address in its place. Reported by an application moving to PostgreSQL (FW-179).

Documentation

  • The Database API Guide says how a DATE_FORMAT pattern is written through prepareQuery().

A schema is no longer glued to the word before a backticked name on PostgreSQL

Fixed

  • insert into`#PREFIX#t`(`a`) values (1) — valid MySQL, no space before the name — became insert intopublic."t"(…) on PostgreSQL, a syntax error at "intopublic". prepareQuery() and prepare() put the schema in front of the quoted name and nothing between it and the word before. A space goes there now, before from, join, update and the rest alike (FW-180).

The query builder quotes a qualified mixed-case column on PostgreSQL

Fixed

  • whereNull('u.registrationStatus') failed on PostgreSQL with "column u.registrationstatus does not exist". The builder quoted a bare mixed-case column, so PostgreSQL would not fold it, but a qualified one only when it was a reserved word. The column of table.column now follows the bare name's rule, in every place the builder names a column. The table or alias in front is still quoted only when it is a reserved word. One application has 79 such columns (FW-181). The Upgrade Guide has the row.

REGEXP, RLIKE and NOT REGEXP on PostgreSQL

Added

  • The MySQL translation reads MySQL's regular-expression operators. a REGEXP p and a RLIKE p become a ~* p, and a NOT REGEXP p becomes a !~* p: case-insensitive, as MySQL matches under the _ci collations tables are usually in. They answered "syntax error at or near REGEXP". REGEXP_SUBSTR() and REGEXP_LIKE() are left as written, since PostgreSQL has both from 15 (FW-182).

A PHP bool reaches a smallint flag on PostgreSQL

Fixed

  • A bool written into a smallint column failed on PostgreSQL with "invalid input syntax for type smallint: \"true\"". A table moved from MySQL keeps its tinyint(1) flags as smallint, and a bool was bound as 'true'/'false', which only a boolean reads. selectOne() wrote TRUE/FALSE into the statement, and smallint = boolean has no operator. Both are '1'/'0' now, which a boolean and a smallint read alike (FW-183).

User::save() writes active on PostgreSQL

Fixed

  • A user saved inactive on PostgreSQL was stored active. User::save() left active out of its fields whenever the database was PostgreSQL, so a new user got the column's default and an existing one kept its old state. Deactivating, deleting or blocking through save() was lost without a word, and one application keeps states 0, 1, 2, 3 and 5 in that column. It is written on every database now. Its type follows the column: an integer for the framework's smallint, a boolean where an older schema declared it boolean, and nothing where there is no such column, as before. A characterization test holds it on MySQL and PostgreSQL (FW-184).

An MCP client sees the tools again

Fixed

  • Claude's MCP client listed no tools at all from a framework server, over stdio and over HTTP alike: connected, and tool_count: 0. Three of the framework's own tools take no arguments and gave 'properties' => [] as their schema, which JSON-encodes as [], a list, where the schema needs an object. The client validates the whole tools/list and drops it for one bad schema. tools/list now sends every empty properties as {}, nested ones and an application's tools included, through the same Llm\Tools::schema() the language-model providers already used, which now goes down into nested objects and array items too (FW-185).

Documentation

  • The MCP Guide says what an empty properties encodes as, and that tools/list takes care of it.

Widening applications.callback no longer stops at a materialized usage_statistics

Fixed

  • widen_applications_callback failed on a database whose applications.usage_statistics was still a materialized view, and the whole migration run stopped with ERROR: "usage_statistics" is not a view. It ran DROP VIEW … CASCADE on both views that block the ALTER without asking what kind of relation each was; create_applications_views already asks. A legacy database imported from an old dump hit it at test bootstrap. The migration now asks getRelationKind(): a materialized view is dropped for the ALTER and recreated from its own definition (pg_matviews), populated if it was, with its indexes. It is not swapped for the live view — that would leave its refresh job and functions pointed at a relation that is no longer materialized, and the application's own migration that adopts the live view would find nothing to clean up. Grants on the materialized view are not carried over.

create_oauth2_application_grants_table replaces a legacy view of another shape

Fixed

  • The migration stopped the run on PostgreSQL with cannot change name of view column "appid" to "tokenid" on a database built from an older dump, where applications.oauth2_active_tokens is still a per-application aggregate. It created both of its views with CREATE OR REPLACE VIEW, which PostgreSQL refuses when the columns change name or order. It now drops oauth2_active_tokens and oauth2_application_permissions with CASCADE first; any framework view that stood on them is recreated by create_applications_views later in the same run. MySQL is unchanged. An installation that has already recorded the migration does not run it again.

Documentation

  • The Migration Guide's checks for a schema a migration did not create gain one: do not rely on CREATE OR REPLACE VIEW on PostgreSQL. It also says how to handle a materialized view that blocks an ALTER.

Model::_load() throws on PostgreSQL when its query fails

Fixed

  • A model loaded from a table that was not there looked loaded and empty on PostgreSQL. A failed prepare returns false there, where MySQL throws, and _load() read ->numRows from it: "Attempt to read property numRows on false", and a model with nothing in it. It throws a QueryException carrying the query now, as getAllOrFail() does (FW-186). The Upgrade Guide has the row.

CONCAT() with a NULL is NULL on PostgreSQL too

Fixed

  • The MySQL translation left CONCAT() as it was, and PostgreSQL's concat() skips a NULL. MySQL's CONCAT('a', NULL) is NULL; PostgreSQL's is 'a'. A column built from a ticket's replies and details was empty on MySQL and filled on PostgreSQL. CONCAT(a, b, …) becomes ((a)::text || (b)::text || …), which is NULL when any part is, and joins a number as text as MySQL does. CONCAT_WS is left alone: both skip a NULL there (FW-187).

applications.oauth2_active_tokens is one row per token again on PostgreSQL; the summary is …_summary

Fixed

  • Two framework migrations defined applications.oauth2_active_tokens on PostgreSQL, with different shapes. create_oauth2_application_grants_table made one row per live token, the definition the authentication server schema was ported from. create_applications_views, which runs after it, dropped that with CASCADE and put a per-application count under the same name. On MySQL the count was always applications_oauth2_active_tokens_summary, so only PostgreSQL had the clash. It surfaced when the grants migration ran again on a database where the count already held the name: the PostgreSQL OR REPLACE cannot rename columns, which is the failure fixed earlier today.
  • create_applications_views now creates the count as applications.oauth2_active_tokens_summary, and a new migration, 2026_10_20_000001_rename_oauth2_active_tokens_summary, moves it on installations that already recorded both. It runs only when applications.oauth2_active_tokens is a plain view with a token_count column. It drops that view without CASCADE and recreates both from their owning migrations' definitions, through the new public CreateOauth2ApplicationGrantsTable::createActiveTokensView() and CreateApplicationsViews::createActiveTokensSummary(). It does not re-run create_applications_views, which would rebuild the continuous aggregates and lose what they had materialized. A materialized view under the name is left alone. MySQL is unchanged.

What breaks

  • SQL that reads the count columns from applications.oauth2_active_tokens on PostgreSQL must read applications.oauth2_active_tokens_summary. Nothing in the framework reads either view. See the Upgrade Guide row.

db:migrate-from-mysql makes a char(n) a varchar(n)

Fixed

  • A CHAR(3) holding 'gr' read back as 'gr ' after the move. MySQL drops a CHAR's trailing spaces when it reads one; PostgreSQL's character(n) hands them back. Comparisons inside PostgreSQL ignore them, but PHP does not: an array key, ===, JSON and the page all saw the padding. One application has 28 such columns, a currency and a country among them. A table the conversion makes gets varchar(n) for a char(n). The values already move trimmed, as MySQL gives them (FW-188).

Documentation

  • The Moving from MySQL Guide's type table says so, and why.

DELETE … LIMIT and UPDATE … LIMIT through query() on PostgreSQL

Fixed

  • $db->query("DELETE FROM t WHERE a = 1 LIMIT 1") answered "syntax error at or near LIMIT" on PostgreSQL, which takes no LIMIT in a DELETE or an UPDATE. prepare() and prepareQuery() already rewrote it as WHERE ctid IN (SELECT ctid FROM t WHERE … LIMIT n), but a statement given to query() went through as written. query() rewrites it too, so a LIMIT %d that prepareQuery() could not read before its substitution is rewritten when the statement runs. One application has some seventy of them (FW-189).

Clearing a cache whose prefix holds a : clears it

Fixed

  • cacheflush(), the administration's Clear Cache and cache:clear left the SQL cache in place on any connection with a port. The SQL cache's prefix carries server:5432. A key was stored under the prefix as a name holds it, colon dropped, and clear() scanned for the prefix with its colon, which matched nothing. The same was true of an installation prefix written myapp:, as this guide's own example had it. Measured: a cached count, a row inserted, a flush, the count again gave 0, 0, 1 on MySQL without a port and 0, 0, 0 on PostgreSQL. The prefix is now made a name's in one place, for saving, reading and clearing alike (FW-190).
  • Entries already cached under such a prefix are not found again — their names held the raw prefix — and expire on their own time; nothing reads a stale value from them.

When the password last changed, and a way back from the change-password page

Added

  • password_changed_at on GET /me (ISO 8601, or null), from the new User::getPasswordChangeDate(): ?int. User::setPassword() dates a change in the passwordchangedate userdetails field when an account that already has a password is given a different one. A first password and the same password again are not dated, so a client can require a change on first sign-in. The comparison uses PasswordHash::verify(), not verifyPassword(), which would upgrade the stored hash as a side effect. A change made before the field was kept is read from the newest password_changed activity-log entry.
  • returnUrl on the change-password page. After a successful change the page sends the user to returnUrl, provided an active application declares its origin: its URL, any of its callbacks, or a CORS origin in its settings. A client_id (apikey) beside it narrows the check to that application. Scheme and host must match exactly, the port only when the declared address names one, and only http(s) is ever accepted. An undeclared address is ignored, and the page behaves as before. Where nothing is declared (the named application, or every active one), any http(s) address is accepted. A client_id that names no active application accepts none. The address survives a failed attempt. The form page adds its origin to form-action, and keeps the address and client in the session (Account::PASSWORD_RETURN_SESSION_KEY), so an application's own copy of the view, which posts only its fields, still returns. Asked for by an authentication server whose client (UW-486) requires a password change on first sign-in.

What breaks in subclasses

  • A User subclass with its own getPasswordChangeDate() must declare it public function getPasswordChangeDate(): ?int. If it did what the base class now does, delete it, together with a setPassword() override that only dated the change.

A webhook endpoint's signing secret can be shown again, and is never empty

Added

  • "Show secret" on the application's webhook card. ApplicationsController::webhooksecret() is a POST with the session's token, for administrators. It shows one endpoint's stored signing secret, decrypted through Encrypter::maybeDecrypt(), so a row stored in plain text shows as stored. It writes a webhook_secret_revealed activity-log entry. The receiver needs the secret to verify X-Webhook-Signature, and until now the only option after losing it was a new secret, which breaks the receiver until it is updated. Read it in code with WebhookService::endpointSecret(). The confirmations after adding or regenerating no longer say the secret cannot be shown again.
  • The add form takes a signing secret. A typed secret is kept, for a receiver that already has one. An empty field gets a generated one. The "New secret" button is now labelled "Regenerate".
  • Generated secrets are whsec_ + 64 hex characters (WebhookService::generateSecret()), everywhere the framework makes one: the admin screen, regenerate and /Webhook/register.

Fixed

  • WebhookService::saveEndpoint() no longer stores an empty secret. Given '', it generates one, so an existing endpoint stored with an empty secret gets one on its next save. A delivery signed with an empty HMAC key proves nothing. The framework's own screens and /Webhook/register always generated one; a direct caller could store ''. An existing endpoint with an empty secret shows as such on "Show secret", which says to issue a new one.

Every built-in webhook type fires, and only to the user's own applications

Fixed

  • Three advertised event types had no emitter. user_deauthorized, account_deleted and scope_changed were in the catalogue, could be subscribed to, and were never queued. user_deauthorized now comes from AuthorizedApplications::revoke() and goes to that application only. account_deleted comes from AccountErasure::erase(), after the erase, to the applications the user had. They are read before the erase deletes the tokens they are found by, and the user is named in the body only, because the event's user_id is a foreign key to the row just removed. scope_changed comes from a consent that grows, to that application, with scopes_added.
  • Two were queued under names nobody could subscribe to. POST /gdpr/request queued gdpr_request_created and POST /gdpr/notifyChange queued profile_changed. They now queue gdpr_request and user_profile_changed.
  • token_revoked from POST /gdpr/deauthorizeAll went to every subscriber of the type, whatever applications the user had. It now goes to the user's applications, read before their tokens are revoked.
  • device_deauthorized failed wherever an endpoint subscribed to it. It was queued with user_id = 0, which the foreign key refuses; it is now queued with none.

Added

  • token_revoked when one live token is revoked (User::deleteToken(), behind DELETE /me/tokens/{id}), to that token's application. gdpr_request on a data export from the account page. user_profile_changed when the profile form changes a field; the body carries the field names, not the values.
  • WebhookService::userEvent() (best-effort, static) and queueUserEvent() queue a user's event for $appIds, or by default for the applications the user holds an unexpired token for or has consented to. Also new: applicationsOfUser(), and isSubscribed(), which answers false without asking when the webhook tables are missing. The body always starts with event_type and user_id.
  • WebhookEvents::builtIn() lists each type's payload fields. The AuthServer Integration Guide has a table of what fires each type, who receives it, and its body.

whereLike(): text search that ignores case and accents on every database

Added

  • QueryBuilder::whereLike($column, $pattern) and orWhereLike(). A LIKE that ignores case and accents on every database, so '%μαρια%' finds Μαρία. MySQL compares so already under its _ci collations and gets a plain LIKE. On PostgreSQL the column and the pattern are folded alike with translate(): upper case to lower, accents and diaeresis to the bare letter, ς to σ, for Greek and Latin. ILIKE leaves accents apart, and lower() follows a locale that under C leaves Greek alone.

Fixed

  • A datatable's search on PostgreSQL missed a name typed in lower case or without its accents. Datasource's search box and its column filters searched with where(…, 'LIKE', …), which is case- and accent-sensitive there. They go through whereLike() now. One application has 124 administration tables searched this way (FW-191).

An application can leave the framework's help pages out

Added

  • 'help' => ['framework' => false] leaves the framework's own administration help pages (resources/help/) out of the application's help. Help::directories() then returns only the application's directories, and the admin HelpBook gets no framework directory, so every page in them is the application's. Until now directories() always appended the framework's directory. An application that documents its own screens had /Help list and search pages for screens it does not use, and its operators read those as features they have. useDirectories() could not help, because the book takes the last directory as the framework's. New: Help::includesFramework(). Left unset, nothing changes.

mail:flush waits for mail queued for later

Fixed

  • mail:flush sent at once a message queued for later. It took every pending row inside the deadline, with no date <= now, so a reminder an application had queued for tomorrow went out today. One application had switched the command off over it. A row dated in the future now stays pending until its time. The statement that gives up on rows past the deadline goes through the query builder (FW-195).

The daemon orchestrator stops its daemons before it exits, and its job name is its own

Fixed

  • On SIGTERM the orchestrator exited and left its daemons behind. Run by hand they outlived it; under the documented systemd unit (KillMode=mixed) systemd signals only the orchestrator, so once it had gone the workers were SIGKILLed, a task in hand with them. On SIGTERM or SIGINT it now asks each daemon to stop after its current task, gives them STOP_GRACE_SECONDS (30), and sends SIGTERM only to one still running then (FW-198).
  • A subclass implementing the three methods the class names failed at load with "contains 1 abstract method": getJobName(), which CommandBase declares. DaemonOrchestrator gives it, daemon_orchestrator, the name the scaffolded Daemons command has always used (FW-197).

Queue handlers from app.php, and a worker that takes only what it can run

Added

  • 'queue' => ['handlers' => [type => class]] in app.php, read by every worker: queue:process runs an application's task types with no ProcessQueue subclass — which the orchestrator's queue pools need, since they start the command by the framework's name. Order: registered in code, then app.php, then the framework's own (FW-192).
  • QueueManager::extraColumns(), a protected hook: columns of an application's own — a meta_data — written with every task, from addTask() and addMany(). A framework column of the same name keeps the framework's value (FW-193).

Fixed

  • A worker not given --type claimed every task type, then failed the ones it had no handler for with "No handler registered", spending an attempt of the worker that could run them. It claims only the types it has handlers for; handledTaskTypes() is the override (FW-193).
  • addTask(unique: true)'s duplicate check goes through the query builder, on getQueueTableName().

Scheduled commands in process, and the queue's retention as configuration

Added

  • ScheduledTask::inProcess() and FrameworkSchedule::inProcess(…): a scheduled command runs inside work / schedule:run — same exit code and output — instead of a PHP start for every run; a task every minute was 1,440 of them a day. Opt-in, because in process a command shares the scheduler's static state, memory and fatal errors (FW-194).
  • 'queue' => ['retention_hours' => N] in app.php: how long the framework's daily queue:cleanup keeps finished tasks, instead of a fixed 24 hours; --hours still wins (FW-196).

Alerts in the Tailwind views read as one block

Fixed

  • "The orchestrator is not running." on the Services screen was three narrow columns, its text breaking word by word. daisyUI's .alert is a grid that gives each child a column, and the message was a title, a sentence and a paragraph. Six more alerts had the same shape: the stale-heartbeat one beside it, the OAuth2 fallback, the two sign-in states, the administration screens' note, the backup codes' "Setup complete!" and the account deletion warning. Each now carries block, as the views that got it right already did, and TailwindAlertLayoutTest fails on an alert that does not (FW-199).

The queue screen is a DataTable, with stop, duplicate and an application's own columns

Added

  • The administration's Queue is a DataTable fed by Queue/data: a status filter, search over the type, the error and the payload, a page size, and sorting by id, type and status — the indexed columns. It was a plain table of fifty rows a page, over tables that run to tens of thousands of tasks (FW-199).
  • Stop a pending or processing task: status stopped, its lock cleared. No worker claims it, addTask(unique: true) does not count it, and queue:cleanup removes it with the completed ones. A new migration lets PostgreSQL's status constraint and enum accept it.
  • Duplicate a task: a new pending copy with the same type, payload, priority and attempts allowed.
  • QueueController::extraColumns(): a wrapper names columns of the queue table's own (['Sent by' => 'meta_data']), shown and searched in the list and copied by Duplicate.

mail:flush takes an application's own checks, counts and switch

Added

  • Hooks around each send, for an application's subclass: beforeSend() returns a reason not to send — the row is failed with it — afterSent() is called for each message that went, and afterFailed() for each failed for good, refused, bounced or refused by the server, not for a retry. So an application's blocklist and its daily count move to mail:flush without a copy of deliver() (FW-200).
  • mail.outbox.enabled (or an override of sendingEnabled()): switched off, nothing is sent and nothing given up on.
  • mail.outbox.deadline never (and --deadline=never): a message is not failed for its age, so mail older than a day still goes after a backlog or an outage.
  • mail.outbox.from: the sender of a row that has none, instead of admin_mail with the site's name; defaultSender() is the override.

The Roadmap notes a dashboard test that sometimes skips itself

Documentation

  • DashboardLayoutsMySQLTest::testUntrackedMailShowsNoOpenRate skipped itself in one full run because another test had left tracking rows from this week, and did not in the next. Where the rows come from is not yet found; the Roadmap carries what to do and when it is done.

Queued mail keeps its names, and a refusal for now waits before the next attempt

Fixed

  • A queued message lost its sender's and its recipient's names. Email wrote fromname and toname empty, and mail:flush set only the addresses, so mail from ['info@example.com' => 'The Shop'] went out as a bare info@example.com — the framework's own queued mail included. The names are kept for a message with one address on each side, and put back when it is sent (FW-202).
  • A refusal for now was retried on every run until the deadline: 1,440 attempts a day, and with the mail server down every queued message opened a connection every minute. After its n-th such refusal a message waits base · 2^(n−1) seconds, at most max — 60 and 3600 unless mail.outbox.backoff says otherwise, false for the old behaviour. Kept in the row's extrainfo, so the table — possibly a compressed hypertable — needs no new column.

A notification reaches an open page at once

Added

  • With broadcasting.transport websocket or pusher, the bell hears of a notification as it is written. DatabaseChannel announces notification.created, with the row's id, on the recipient's channel — private-users.{id}, or broadcasting.notifications_channel. Best effort: a failed broadcast is logged and the row stays. Every sender works unchanged (FW-201).
  • A signed-in page carries <meta name="pf-realtime">, and pf-utils.js connects from it with a dependency-free Pusher-protocol client, window.pfRealtime. It signs private channels through /broadcasting/auth with the CSRF token, answers and sends pings, and reconnects with jittered backoff; after a dropped connection comes back it dispatches one pf:notifications:refresh, since nothing published while it was away is kept. A page that defines its own window.pfRealtime keeps it.
  • NotificationBell listens on the user's own channel by default, and the default channel registry admits a user to that channel and to nobody else's (ChannelRegistry::ownUserChannel). 'channel' => '' keeps a bell off the connection. Polling stays, as the safety net.

The client was taken from an application whose chat already runs on it. A WebSocket, not SSE: on PHP-FPM every open stream holds a worker.

Fixed

  • DatabaseChannel writes through the query builder instead of raw SQL.

The queue screen shows what the queue is doing, each task, and its last two days

Added

  • The list opens with a card per status — completed with its share — and a table per task type over the last ten minutes: running, waiting, done and net per minute, and when it clears; a type that is not keeping up reads "growing — needs workers". The table has a Type filter beside the Status one, and Priority, Started, Completed and Execution time columns (FW-199, as the queue screen of an application in production already had them).
  • A task's own page, Queue/show/<id>: every field it has — lock, batch, task hash, CPU time, the whole error and result, the application's columns — the payload key by key as readable JSON, and the actions its status allows.
  • Queue/history: the hourly roll-up per task type over the last 48 hours, from queuestats, which outlives the purge.

Fixed

  • The Stats button opened a page of raw JSON. No page links to Queue/stats any more; the figures are on the list, and the endpoint stays for whatever monitors it (FW-203).
  • The screen was English on a Greek site. Every label, column, button, confirmation and status goes through t(), with the Greek in the framework's catalogue (FW-203).

The queue screen's time columns say they are times

Fixed

  • "Started" and "Completed" over the queue screen's time columns took an application's own translation of the bare word — one application has «Started» as a state, «Εκκρεμεί» — since an application's catalogue wins over the framework's. The time fields read "Created at", "Updated at", "Started at" and "Completed at"; the framework's catalogue no longer carries the bare "Created", "Started" and "Updated", which nothing of its own used any more (FW-205).

Email history: failed reads Failed, and an application's statuses, columns and resend

Added

  • Hooks on EmailsController so an application's own Email Log can go: statuses() (the filter's values and labels), statusBadge($status, $row), extraColumns() — shown, searched, escaped — and mayResend($row) (FW-204).
  • Resend of a sent message, where mayResend() allows it, queues a new copy and leaves the sent row as it was, so the history keeps both. An unsent one goes back to the queue, dated now. Resend is a POST with the session's token.
  • Every label of the screen goes through t(), with the Greek in the framework's catalogue.

Fixed

  • A failed message read "Pending". Status 0 is Failed, with the reason — plain text, the outbox's last error, or an application's serialized error — in the badge's title.
  • The bootstrap and plain-css lists always said "No emails found." They were still the old hand-written table, reading a variable the controller no longer gives; they draw the datatable as tailwind does.

Http\Client::json() throws on data it cannot encode

Fixed

  • A body json_encode() could not encode went out empty. json() cast the encoder's false to an empty string and sent it as application/json; an issue tracker answered "no payload supplied", and the cause — a byte of a Greek letter cut in two by a pattern without /u, upstream — was in no message anywhere. It throws an InvalidArgumentException naming the encoder's error, with the JsonException as its previous, and nothing is sent (FW-206).

broadcast:serve reads app.php and reaches every user's notifications

Added

  • broadcast:serve takes its address, key and ingest from app.php: broadcasting.websocket.listen_host / listen_port / app_key and broadcasting.ingest, each overridden by its option. One supervisor line, broadcast:serve, is enough (FW-205).
  • Every user's notifications channel, without naming it. With the redis backplane the server PSUBSCRIBEs <prefix>private-users.*, so the notification.created that DatabaseChannel announces reaches an open page — before, only channels listed by name did, and private-users.{id} cannot be listed. RedisSubscriberSocket subscribes a name with * as a pattern.

Fixed

  • A message arrived on the prefixed channel. What the driver published on app:chat.updates went to subscribers of app:chat.updates, which no client asks for; the prefix is taken off.
  • The redis-stream ingest read a key nobody writes — <prefix><channel> instead of the driver's <prefix>stream:<channel>.
  • Loopback by default. The server bound 0.0.0.0; it binds 127.0.0.1, for the reverse proxy in front of it. listen_host or --host opens it.

The orchestrator waits out its whole shutdown grace

Fixed

  • The grace before a daemon is SIGTERMed was shorter than it said. Each of its thirty seconds was a sleep(1), which a signal — a worker exiting is a SIGCHLD — cuts short; each one now waits out the whole second.
  • A lock test raced its own background process — it gave it 150 ms to take the lock and failed on a loaded machine; it waits until the lock is held.

An admin shell's breadcrumb is the only one, and the email preview stops throwing

Added

  • 'admin' => ['breadcrumb' => 'theme'] for an application whose admin shell draws its own trail from addbreadcrumb(). The framework's screens hand it their steps and print nothing; without the setting they draw the trail as before. Html\Breadcrumb::admin($steps) does both, and the three themes' admin_breadcrumb partials call it (FW-207).

Fixed

  • A second, unstyled breadcrumb above the email history in an application with its own shell — "Home / Dashboard / Email history / #1452", one crumb a line.
  • The email preview threw on every message. Its onload sized the frame from contentDocument, which a sandbox without allow-same-origin makes null — and as an inline handler a CSP without unsafe-inline blocks it. The frame has a fixed height the reader can drag taller, and no handler; the sandbox is unchanged.

The queue's task and statistics pages leave the breadcrumb to the shell too

Fixed

  • Queue/show/<id> and Queue/history still printed their own trail with 'admin' => ['breadcrumb' => 'theme'] — "Home / Queue / #216" on three plain lines above the Back button. They built a Breadcrumb of their own; they go through Breadcrumb::admin(), and a test fails for any admin view that builds one again (FW-207).
  • Home and Dashboard are translated in the trail the screens draw themselves.