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 aDATE_FORMATpattern 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,RLIKEandNOT REGEXPon PostgreSQL- A PHP bool reaches a smallint flag on PostgreSQL
User::save()writesactiveon PostgreSQL- An MCP client sees the tools again
- Widening
applications.callbackno longer stops at a materializedusage_statistics create_oauth2_application_grants_tablereplaces a legacy view of another shapeModel::_load()throws on PostgreSQL when its query failsCONCAT()with a NULL is NULL on PostgreSQL tooapplications.oauth2_active_tokensis one row per token again on PostgreSQL; the summary is…_summarydb:migrate-from-mysqlmakes achar(n)avarchar(n)DELETE … LIMITandUPDATE … LIMITthroughquery()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 databasemail:flushwaits 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:flushtakes 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 encodebroadcast:servereadsapp.phpand 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.
Accounthanded their views'Single Sign-On'and'Create Account'as literals, and the views print$this->headeras given. The two success messagesTwoFactorAuthhands its views ("New backup codes generated…", "Two-factor authentication has been enabled successfully!") were English the same way. All four go throught(), 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, andlastseenholdstime(). 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 invar/schema-generationthat moves whenever a batch applies or rolls back anything, and not when nothing was pending.DaemonOrchestratorcompares 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()andhasColumn()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)threwValueError: Unknown format specifier "Y"on PostgreSQL. The MySQL translation ran before printf, and read%%Yas MySQL would: a literal percent, then aY. It kept that as text, and printf then met%Y.prepareQuery()now has the pattern read with%%as%, and theto_charpattern that comes out keeps any literal percent doubled for printf. The same call givesto_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_FORMATpattern is written throughprepareQuery().
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 — becameinsert intopublic."t"(…)on PostgreSQL, a syntax error at "intopublic".prepareQuery()andprepare()put the schema in front of the quoted name and nothing between it and the word before. A space goes there now, beforefrom,join,updateand 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 oftable.columnnow 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 panda RLIKE pbecomea ~* p, anda NOT REGEXP pbecomesa !~* p: case-insensitive, as MySQL matches under the_cicollations tables are usually in. They answered "syntax error at or near REGEXP".REGEXP_SUBSTR()andREGEXP_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
smallintcolumn failed on PostgreSQL with "invalid input syntax for type smallint: \"true\"". A table moved from MySQL keeps itstinyint(1)flags assmallint, and a bool was bound as'true'/'false', which only abooleanreads.selectOne()wroteTRUE/FALSEinto the statement, andsmallint = booleanhas no operator. Both are'1'/'0'now, which abooleanand asmallintread alike (FW-183).
User::save() writes active on PostgreSQL¶
Fixed¶
- A user saved inactive on PostgreSQL was stored active.
User::save()leftactiveout 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 throughsave()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'ssmallint, 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 wholetools/listand drops it for one bad schema.tools/listnow sends every emptypropertiesas{}, nested ones and an application's tools included, through the sameLlm\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
propertiesencodes as, and thattools/listtakes care of it.
Widening applications.callback no longer stops at a materialized usage_statistics¶
Fixed¶
widen_applications_callbackfailed on a database whoseapplications.usage_statisticswas still a materialized view, and the whole migration run stopped withERROR: "usage_statistics" is not a view. It ranDROP VIEW … CASCADEon both views that block the ALTER without asking what kind of relation each was;create_applications_viewsalready asks. A legacy database imported from an old dump hit it at test bootstrap. The migration now asksgetRelationKind(): 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, whereapplications.oauth2_active_tokensis still a per-application aggregate. It created both of its views withCREATE OR REPLACE VIEW, which PostgreSQL refuses when the columns change name or order. It now dropsoauth2_active_tokensandoauth2_application_permissionswithCASCADEfirst; any framework view that stood on them is recreated bycreate_applications_viewslater 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 VIEWon PostgreSQL. It also says how to handle a materialized view that blocks anALTER.
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
falsethere, where MySQL throws, and_load()read->numRowsfrom it: "Attempt to read property numRows on false", and a model with nothing in it. It throws aQueryExceptioncarrying the query now, asgetAllOrFail()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'sconcat()skips a NULL. MySQL'sCONCAT('a', NULL)isNULL; 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_WSis 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_tokenson PostgreSQL, with different shapes.create_oauth2_application_grants_tablemade one row per live token, the definition the authentication server schema was ported from.create_applications_views, which runs after it, dropped that withCASCADEand put a per-application count under the same name. On MySQL the count was alwaysapplications_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 PostgreSQLOR REPLACEcannot rename columns, which is the failure fixed earlier today. create_applications_viewsnow creates the count asapplications.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 whenapplications.oauth2_active_tokensis a plain view with atoken_countcolumn. It drops that view withoutCASCADEand recreates both from their owning migrations' definitions, through the new publicCreateOauth2ApplicationGrantsTable::createActiveTokensView()andCreateApplicationsViews::createActiveTokensSummary(). It does not re-runcreate_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_tokenson PostgreSQL must readapplications.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 aCHAR's trailing spaces when it reads one; PostgreSQL'scharacter(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 getsvarchar(n)for achar(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 noLIMITin aDELETEor anUPDATE.prepare()andprepareQuery()already rewrote it asWHERE ctid IN (SELECT ctid FROM t WHERE … LIMIT n), but a statement given toquery()went through as written.query()rewrites it too, so aLIMIT %dthatprepareQuery()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 andcache:clearleft the SQL cache in place on any connection with a port. The SQL cache's prefix carriesserver:5432. A key was stored under the prefix as a name holds it, colon dropped, andclear()scanned for the prefix with its colon, which matched nothing. The same was true of an installation prefix writtenmyapp:, 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_atonGET /me(ISO 8601, ornull), from the newUser::getPasswordChangeDate(): ?int.User::setPassword()dates a change in thepasswordchangedateuserdetails 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 usesPasswordHash::verify(), notverifyPassword(), which would upgrade the stored hash as a side effect. A change made before the field was kept is read from the newestpassword_changedactivity-log entry.returnUrlon the change-password page. After a successful change the page sends the user toreturnUrl, provided an active application declares its origin: its URL, any of its callbacks, or a CORS origin in its settings. Aclient_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 onlyhttp(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), anyhttp(s)address is accepted. Aclient_idthat names no active application accepts none. The address survives a failed attempt. The form page adds its origin toform-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
Usersubclass with its owngetPasswordChangeDate()must declare itpublic function getPasswordChangeDate(): ?int. If it did what the base class now does, delete it, together with asetPassword()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 throughEncrypter::maybeDecrypt(), so a row stored in plain text shows as stored. It writes awebhook_secret_revealedactivity-log entry. The receiver needs the secret to verifyX-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 withWebhookService::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/registeralways 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_deletedandscope_changedwere in the catalogue, could be subscribed to, and were never queued.user_deauthorizednow comes fromAuthorizedApplications::revoke()and goes to that application only.account_deletedcomes fromAccountErasure::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'suser_idis a foreign key to the row just removed.scope_changedcomes from a consent that grows, to that application, withscopes_added. - Two were queued under names nobody could subscribe to.
POST /gdpr/requestqueuedgdpr_request_createdandPOST /gdpr/notifyChangequeuedprofile_changed. They now queuegdpr_requestanduser_profile_changed. token_revokedfromPOST /gdpr/deauthorizeAllwent 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_deauthorizedfailed wherever an endpoint subscribed to it. It was queued withuser_id = 0, which the foreign key refuses; it is now queued with none.
Added¶
token_revokedwhen one live token is revoked (User::deleteToken(), behindDELETE /me/tokens/{id}), to that token's application.gdpr_requeston a data export from the account page.user_profile_changedwhen the profile form changes a field; the body carries the field names, not the values.WebhookService::userEvent()(best-effort, static) andqueueUserEvent()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(), andisSubscribed(), which answers false without asking when the webhook tables are missing. The body always starts withevent_typeanduser_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)andorWhereLike(). ALIKEthat ignores case and accents on every database, so'%μαρια%'findsΜαρία. MySQL compares so already under its_cicollations and gets a plainLIKE. On PostgreSQL the column and the pattern are folded alike withtranslate(): upper case to lower, accents and diaeresis to the bare letter,ςtoσ, for Greek and Latin.ILIKEleaves accents apart, andlower()follows a locale that underCleaves 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 withwhere(…, 'LIKE', …), which is case- and accent-sensitive there. They go throughwhereLike()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 adminHelpBookgets no framework directory, so every page in them is the application's. Until nowdirectories()always appended the framework's directory. An application that documents its own screens had/Helplist 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:flushsent at once a message queued for later. It took every pending row inside the deadline, with nodate <= 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 themSTOP_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(), whichCommandBasedeclares.DaemonOrchestratorgives it,daemon_orchestrator, the name the scaffoldedDaemonscommand 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]]inapp.php, read by every worker:queue:processruns an application's task types with noProcessQueuesubclass — which the orchestrator's queue pools need, since they start the command by the framework's name. Order: registered in code, thenapp.php, then the framework's own (FW-192).QueueManager::extraColumns(), a protected hook: columns of an application's own — ameta_data— written with every task, fromaddTask()andaddMany(). A framework column of the same name keeps the framework's value (FW-193).
Fixed¶
- A worker not given
--typeclaimed 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, ongetQueueTableName().
Scheduled commands in process, and the queue's retention as configuration¶
Added¶
ScheduledTask::inProcess()andFrameworkSchedule::inProcess(…): a scheduled command runs insidework/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]inapp.php: how long the framework's dailyqueue:cleanupkeeps finished tasks, instead of a fixed 24 hours;--hoursstill 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
.alertis 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 carriesblock, as the views that got it right already did, andTailwindAlertLayoutTestfails 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, andqueue:cleanupremoves 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, andafterFailed()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 tomail:flushwithout a copy ofdeliver()(FW-200). mail.outbox.enabled(or an override ofsendingEnabled()): switched off, nothing is sent and nothing given up on.mail.outbox.deadlinenever(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 ofadmin_mailwith the site's name;defaultSender()is the override.
The Roadmap notes a dashboard test that sometimes skips itself¶
Documentation¶
DashboardLayoutsMySQLTest::testUntrackedMailShowsNoOpenRateskipped 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.
Emailwrotefromnameandtonameempty, andmail:flushset only the addresses, so mail from['info@example.com' => 'The Shop']went out as a bareinfo@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 mostmax— 60 and 3600 unlessmail.outbox.backoffsays otherwise,falsefor the old behaviour. Kept in the row'sextrainfo, so the table — possibly a compressed hypertable — needs no new column.
A notification reaches an open page at once¶
Added¶
- With
broadcasting.transportwebsocketorpusher, the bell hears of a notification as it is written.DatabaseChannelannouncesnotification.created, with the row's id, on the recipient's channel —private-users.{id}, orbroadcasting.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">, andpf-utils.jsconnects from it with a dependency-free Pusher-protocol client,window.pfRealtime. It signs private channels through/broadcasting/authwith the CSRF token, answers and sends pings, and reconnects with jittered backoff; after a dropped connection comes back it dispatches onepf:notifications:refresh, since nothing published while it was away is kept. A page that defines its ownwindow.pfRealtimekeeps it. NotificationBelllistens 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¶
DatabaseChannelwrites 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, fromqueuestats, which outlives the purge.
Fixed¶
- The Stats button opened a page of raw JSON. No page links to
Queue/statsany 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
EmailsControllerso an application's own Email Log can go:statuses()(the filter's values and labels),statusBadge($status, $row),extraColumns()— shown, searched, escaped — andmayResend($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'sfalseto an empty string and sent it asapplication/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 anInvalidArgumentExceptionnaming the encoder's error, with theJsonExceptionas its previous, and nothing is sent (FW-206).
broadcast:serve reads app.php and reaches every user's notifications¶
Added¶
broadcast:servetakes its address, key and ingest fromapp.php:broadcasting.websocket.listen_host/listen_port/app_keyandbroadcasting.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
redisbackplane the serverPSUBSCRIBEs<prefix>private-users.*, so thenotification.createdthatDatabaseChannelannounces reaches an open page — before, only channels listed by name did, andprivate-users.{id}cannot be listed.RedisSubscriberSocketsubscribes a name with*as a pattern.
Fixed¶
- A message arrived on the prefixed channel. What the driver published on
app:chat.updateswent to subscribers ofapp:chat.updates, which no client asks for; the prefix is taken off. - The
redis-streamingest 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 binds127.0.0.1, for the reverse proxy in front of it.listen_hostor--hostopens 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 fromaddbreadcrumb(). 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_breadcrumbpartials 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
onloadsized the frame fromcontentDocument, which a sandbox withoutallow-same-originmakes null — and as an inline handler a CSP withoutunsafe-inlineblocks 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>andQueue/historystill printed their own trail with'admin' => ['breadcrumb' => 'theme']— "Home / Queue / #216" on three plain lines above the Back button. They built aBreadcrumbof their own; they go throughBreadcrumb::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.