Skip to content

2026

21 September 2026

13 changes:

  • A generated controller could serve another tenant's row
  • GDPR erasure had no extension point — and could not run at all without authserver
  • The generated list endpoint disagreed with the read of the same row
  • The scaffolded SPA client could not send a file
  • sURL is the script's base, so the framework now has a site root
  • The scaffolded translation endpoint has never once answered
  • Two scaffolded views landed where the resolver never looks
  • The scaffold ships a screen sweep now
  • A migration that adds a column left the model's column list an hour stale
  • There was no supported way to post a form in a test
  • The screen sweep asked for view and edit without a subject
  • …and left the framework's own tables for every application to seed
  • The bind-mount blink came back, in the half of the fix that had no retry

18 September 2026

3 changes:

  • Two migrations asked information_schema a question MySQL answers differently
  • The unexplained test-suite flakes were TimescaleDB's scheduler segfaulting the server
  • …and the other one was the bind mount saying a directory was not there

17 September 2026

26 changes:

  • The activity log filled with an entry nobody can act on
  • …and two tokens minted in the same second were the same token
  • One unlayered rule drew a border on every daisyUI component
  • Every generated SPA form was written in daisyUI 4
  • The scaffolded CLAUDE.md taught a controller that fatals at autoload
  • timescale:ensure reported conversions and policies that did not happen
  • Three framework migrations failed for ever on a TimescaleDB most hosts can install
  • The scaffolded git webhook fatalled on the first request it ever received
  • …and the scaffolded .gitignore committed half of a key pair, next to a hard-coded Redis host
  • health:check now says which TimescaleDB this is, and what it actually built
  • The suite moved to the oldest supported TimescaleDB, and found four things that day
  • The continuous-aggregate fallback is removed: it could not tell two causes apart
  • A migration that did nothing for a missing capability was recorded as applied
  • The TimescaleDB capability was answered from config, not from the database
  • …so migrate creates the extension itself now, or stops and says how
  • Forty tests that could not fail, and a guard that could not either
  • Three tests that had never run anywhere, and the supported-version claim
  • Forty-five sweeps that would have passed over nothing
  • create:command wrote to a directory the scaffold does not use
  • The ORM guide documented a query API the class does not have
  • create:crud generated a cross-tenant write, and a model that cannot be scoped
  • …and a search source against a column the table does not have
  • Scaffolded code named three classes that do not exist, and the webhook fix was half applied
  • The administrator a scaffold creates is Root now, not one tier below it
  • A tracking write could sign somebody out, and a 401-character URL made it
  • Eight controllers ended a request eight ways, and one of them ended the test run

16 September 2026

18 changes:

  • An OAuth2 client, beside the server the framework already was
  • No grant could issue a refresh token on MySQL
  • Test fixtures stopped inventing their own applications table
  • …and their own usertokens, which was hiding a second one
  • A test failed with "No such file or directory" because of what another test restored
  • A SPA's coverage number was mostly the framework's debug panel
  • Every anonymous request emitted a PostgreSQL syntax error, 430,000 a day
  • Four defects in what the scaffolder generates, one of which blocked all coverage
  • The oauthconnections table sheds an index it never needed, and gains 2038
  • A scaffolded project's tests ran against the development database
  • …and the fix for it was a no-op, twice over
  • A scaffolded project's admin screens all answered 404, and the dev panel could end a test run reporting success
  • Every generated SPA screen fetched a doubly-prefixed URL, and a test asserted it
  • A SPA could not offer the one sign-in that needs no password
  • …and a user with two-factor authentication could not sign into one at all
  • The session exchange signed with a key the API does not verify with
  • One dead token turned the whole application into "the backend is down"
  • /robots.txt and /llms.txt answered 404, and then pointed at three more

15 September 2026

8 changes:

  • A comment inside a palette block became a colour token
  • The SPA shell's service-worker registration was a fatal
  • Bundled theme stylesheets read custom properties nothing declared
  • The tailwind header showed the dark-ink logo on the dark theme
  • A scaffolded front end arrived with two red tests and a build warning
  • The generated controller contract test named twelve classes that do not exist
  • "One palette, every UI system" was true of one system
  • Three more holes in the palette pipeline, found by walking it end to end

14 September 2026

4 changes:

  • Saving a setting was check-then-act
  • A migration test left the settings table dropped for everything after it
  • A second parameter added a week ago was a fatal for anyone overriding the method
  • Every signature that moved since v1.2 now has a row in the Upgrade Guide

8 September 2026

9 changes:

  • Registering a redirect URI is recommended, not required
  • An array as a cache id put every such call in one entry
  • Compression was making five hypertables bigger, and two migrations could not alter one
  • A failed save answered the customer their own database schema
  • A mistyped API path answered 500 with a trace naming the signed-in user
  • One client, three environments: callback holds a list, and the CSP stops enforcing
  • Moving the ledger replayed 91 already-applied migrations; neither entry point will again
  • Two of the three developer tools could not be given to a person
  • A breaking change had nowhere to be recorded, so it was recorded as a fix

7 September 2026

27 changes:

  • Adminer answered «Invalid CSRF token» on every query, and took your session with it
  • Turning the debug toolbar on from the DevPanel appeared to do nothing
  • One redis connection per cache category, never closed — and the class that was never missing
  • Turning the cache off did not turn the cache off
  • Adminer answered «Invalid CSRF token» again, for the opposite reason — and a database password in your session file
  • component_classes.breadcrumb was read before app.php was loaded
  • The debug-bar switch was unstyled, unreachable on a narrow window, and its CSRF check was never registered
  • calcParams() handed a second parse the first parse's results
  • The queue can say when it is losing, and give back what a dead worker was holding
  • Two workers could claim the same task, and a worker asked to stop kept claiming
  • Every query sent a SELECT 1 first, and it broke FOUND_ROWS()
  • The atomic claim was correct and slower than the race it fixed
  • _(null) was a white page
  • Cache invalidation walked the whole redis keyspace, once per model save
  • An updated row stayed in every cached list that contained it
  • Listing the cache's categories used KEYS, which blocks the server
  • The queue records CPU time beside wall time, so waiting can be told from working
  • The daemon orchestrator capped every pool at nine workers, silently
  • The three open items from the queue inventory: burst sizing, deploy detection, and Ctrl+C
  • spool:drain ran KEYS every minute, and the cache's names set never expired
  • The debug-bar switch threw you to the front page, and no test could have seen why
  • A redirect to HTTP_REFERER, six double decodes, and a query asked three times
  • The test suite is three minutes; the default ./dockertest spends seven more on Xdebug
  • form-action 'self' cancelled every SSO login that went through the login form
  • The authorization endpoint redirected anywhere the request asked, with a code attached
  • An OAuth server that could not say why it refused, and a logger that never truncated
  • The debug switch said it was enabled and no Set-Cookie ever went out

6 September 2026

12 changes:

  • A token issued by the framework's own OAuth2 server had no usable scopes
  • Seven more readers of scope, and a fourth shape the parser did not take
  • A JWT test that reddened the suite on a second boundary
  • One foreign Database class took the whole MCP server with it
  • Scopes said to subclass it, and then never asked the subclass
  • The migrations ledger was created without the prefix and checked with it
  • db-inspect withheld the one number its docblock promised
  • The debug toolbar can be turned on from a screen, not only from a shell
  • The DevPanel opens on a live server for people you name, not for APP_DEBUG
  • Adminer answers while the site is in maintenance
  • A redis blip killed queue workers, and the ledger under-reported it threefold
  • An optional credential would have been stored in plaintext with no key

5 September 2026

13 changes:

  • Helpers::fileGetContents() fetched with TLS verification off
  • A clamped rendition was never found again, and rebuilt on every request
  • Two migrations that could fail on real data now decline instead
  • Saving a token through the model stored it in plaintext and unfindable
  • get() takes upscaling per call, so a template can fill its frame
  • An application on the legacy version ledger can say what already ran
  • crop and resample are part of a rendition's identity too
  • Maintenance mode can be raised and cleared from the console
  • migrations.auto: stop migrating on your own without disabling migrate
  • Ask a production database a question without opening a shell on it
  • The public MCP endpoint ships one tool, so it can be told from a broken one
  • The diagnostic tools can answer off the box, behind three separate scopes
  • mcp:token — one command for the credential and the client config

4 September 2026

9 changes:

  • getRelationKind() — the question to ask before a DROP VIEW
  • usage_statistics is left to whoever owns the name
  • geographic_analysis builds whether ip_address is VARCHAR or INET
  • An aggregate that already exists keeps its definition, and says so
  • migrate and migrate:status read this installation's configuration
  • When a continuous aggregate is possible, and when it cannot be
  • A migration whose statements were all rejected no longer reads as one that worked
  • createTable() puts a schema-qualified table in its schema
  • The maintenance page no longer fatals on an entry point without DS

3 September 2026

7 changes:

  • The notification dispatcher had no guide — it was documented in fragments, in the guides of the channels it delivers to
  • A channel that threw took every channel after it down with it
  • The «unknown channel» message named the one remedy that cannot work from where it is read
  • The log channel was the only one that died on a notifiable that is not an object
  • A docblock claimed a check the screen does not make — and a test explained why it should not
  • The outbox that has been in the schema since 2020, and that nothing ever wrote a row to
  • The repository history was rewritten: what to do if you have a clone

The notification dispatcher had no guide

Notifier is the piece that turns one event into a mail, a stored feed row and a push notification. It had no page. What existed was accurate and scattered: the email guide explained the recipient's language, the push guide showed a notification class as the way to reach toPush(), the internationalization guide mentioned the language switch again from the other side, the security guide explained SecurityChangeNotifier. Somebody who did not already know the dispatch model existed had no way to find it, and somebody who did had to read four pages to assemble it.

Pramnos_Notifications_Guide.md is the page: the three contracts, the five channels and what each reads, routing per recipient and per channel, the language switch, transactional versus list, and how to add a channel the framework does not ship.

Writing it surfaced four behaviours that were true, load-bearing, and written down nowhere.

registerChannel() does not reach notify()

The alias registry is an instance property. NotifiableTrait::notify() constructs its own new Notifier(), so an alias registered anywhere else is invisible to it and a via() returning that alias throws Unknown notification channel.

This is not a defect — a global mutable channel registry is worse, and there is already a form that works everywhere:

public function via(mixed $notifiable): array
{
    return ['mail', \App\Notifications\Channels\SmsChannel::class];
}

Any FQCN implementing ChannelInterface is accepted as a channel name. It needs no registration, so it works through notify(), through a service provider, and in a test. registerChannel() is for the case where you also own the Notifier doing the sending — which, usefully, is exactly what a test owns.

A channel that throws abandons the channels after it

sendNow() loops the channels from via() with no try/catch around the call. Channels skip for missing optional data — that is the documented discipline and the built-in ones keep it — but a channel that raises takes the rest of the list with it.

So the order in via() is meaningful, and ['mail', 'push'] is not the same as ['push', 'mail']. The durable copy goes first. This is now stated in the guide rather than inferred from the absence of a catch, and it is the first thing to check when the second of two channels is the one that never arrives.

Channels are constructed with no arguments

The built-in channels offer constructor injection — MailChannel(?Email), DatabaseChannel(?Database), LogChannel(string $path) — and the Notifier uses none of it: it does new $class(). The injection is for constructing a channel yourself, in a test or in your own dispatch code. A custom channel needs a usable no-argument constructor, which is the kind of requirement that is obvious once stated and a confusing ArgumentCountError until then.

The trait default cannot be reached with parent::

The routing override is the commonest customisation — a billing address, a per-account preference, suppressing one channel for one person — and the natural way to write it wants the default for everything it does not handle. parent::routeNotificationFor() does not work: the default is a trait method, and the override replaces it outright rather than inheriting past it.

use NotifiableTrait { routeNotificationFor as private defaultRouteFor; }

Trait aliasing, and the guide's example carries the comment saying why.

Two decisions the framework deliberately leaves open

Both are in the guide because a page that documents only the API leaves them to be discovered in production.

Nothing prunes the notifications table. The stored feed grows without limit; retention is the application's to decide, and the created_at and read_at indexes are there to make a sweep cheap.

A security warning may not belong in the stored feed at all. An in-app notification is read by whoever is signed in, which for «your account was signed in to from a new device» is the wrong person in exactly the case worth warning about. NewSignInNotification omits 'database' for this reason and uses mail and push — both of which reach the account's owner rather than its current session. That reasoning was in the class's docblock, where only somebody already reading the class would find it.

A channel that threw took every channel after it down with it

sendNow() looped the channels from via() and called each one with nothing around the call. ChannelInterface asks channels not to throw and the five built-in ones keep to it — they return early rather than raise when optional data is missing — so this never showed up in the framework's own use. It shows up in the custom channels the framework explicitly invites you to write, and those are the ones talking to somebody else's gateway over a network.

The consequence was that the order of a list decided delivery. ['sms', 'mail'] and ['mail', 'sms'] are the same intent, and only the second one still sends the mail when the SMS gateway times out. Load-bearing, invisible, and written down nowhere.

Each channel now runs in its own try. A failure is logged with the channel's name and the notification's, and the remaining channels are still tried:

Notification channel 'sms' failed for App\Notifications\OrderShipped: cURL error 28

This is not a new policy. PushChannel::deliver() has always wrapped its own batch for the same reason — «one failed batch must not take down whatever queued it» — and this is that rule applied where the loop actually is.

The opt-out, because best-effort is not always right

(new Notifier())->throwOnChannelFailure()->sendNow($user, $notification);

Off by default, because the default has to serve the request path: somebody changing their password should not be shown a failure because an audit broadcast could not connect. Asked for, it re-raises — for a queue worker deciding whether to retry the job, or an administration screen that told an operator «sent» and has to be able to take it back.

And one thing that throws either way

The channel is resolved outside the try. An unknown channel name — a typo in via(), a class that was renamed — is a mistake in the code and not a delivery that failed. Catching it would turn the one error in this subsystem that a test would catch into a line in a log file nobody reads.

Both halves are tested: the channel after a failure is called, and the channel after an unknown name is not.

The «unknown channel» message named the one remedy that cannot work

Unknown notification channel: 'sms'. Register it with Notifier::registerChannel() or pass the
FQCN of a ChannelInterface class.

Somebody reading that has almost always arrived through $user->notify() — and NotifiableTrait::notify() constructs its own Notifier. The alias registry is an instance property, so an alias registered anywhere else does not exist as far as that call is concerned. The message led with the remedy that cannot be applied from where it is read, and mentioned the one that always works second, as an afterthought.

Reversed, and it now says why:

Unknown notification channel: 'sms'. Return the fully-qualified class name of a ChannelInterface
implementation from via() — that needs no registration and works through notify().
Notifier::registerChannel() also defines a short alias, but only on the Notifier instance it is
called on, which is not the one notify() builds.

The log channel was the only one that died on a notifiable that is not an object

Notifier::languageOf() reads a language from an array as well as from an object, so arrays reach the channels. DatabaseChannel handles one correctly — it resolves the id to null and skips. LogChannel called get_class() on it, which is a TypeError.

So the one channel whose entire purpose is to make a dispatch visible was the only one that died on it. get_debug_type() returns the class name for an object, so every existing log line is byte-identical; an array now reads "notifiable":"array" instead of taking the process down.

A docblock claimed a check the screen does not make — and a test explained why it should not

UsersController::sendChannels() decides which channels the Send screen offers. Its docblock said «mail needs a valid address, the in-app record needs the notifications table, and push needs a VAPID pair» — and the code returned a constant true for the in-app record, checking nothing.

The obvious reading is that the check was forgotten. It was added, and a test that has been green since the screen was written failed with the reason:

the in-app record is the one channel that always works

Which is right, and the docblock was the thing that was wrong. That array answers «what can this account receive», and every real entry in it is a per-account precondition — an address, a subscribed browser. The notifications table is a property of the installation: if it is missing, nothing works, including the user list the screen was reached from. Gating on it spends a schema query per render to defend against a state in which the application is already down.

Reverted, with the reasoning in the code, and the docblock corrected instead. Worth recording because the tempting move was the wrong one twice over: the mismatch was real, and the half that needed fixing was the prose.

notifications is one of three framework migration directories — with broadcasting and applications — that are not registered features. filterMigrationDirsByEnabledFeatures() is fail-open, so an unregistered directory always runs, and the table is created everywhere. It works by not being declared.

Registering a notifications feature would therefore be a silent breaking change: every installation that does not list it in app.php would stop getting the table, and DatabaseChannel is the one channel that does not skip when its prerequisite is missing — it issues an INSERT, so the failure arrives as a SQL error at send time rather than as a missing feature at boot. The note is now in the migration, where somebody would step in it.

The outbox that has been in the schema since 2020

Email::send() opens an SMTP connection and waits for it, and every notification the framework sends went out that way. For a second-factor code that is correct — somebody is watching the screen for the number. For «your password was changed», sent after the change has already happened to somebody who is looking at something else, it is 200–800ms of a stranger's request spent on a message nobody is waiting for. The address-change path spends it twice: that one mails the new address and the previous one.

The interesting part is what was already there. The mails table has carried this comment since 2020:

Email send history and outbox queue — status 2 = queued for delivery, 1 = sent, 0 = failed

Mail::STATUS_QUEUED = 2 has been declared for as long. Nothing ever wrote one, and no command ever read one. The table has every column a spool needs — recipient, subject, the rendered body, a status, a date, an index on each — and the capability was three methods away from existing for five years.

$email->queue();                    // composed now, delivered later
./yourapp mail:flush                // the other half

Composed now, not by the worker

The message is rendered, wrapped and suppression-checked in the request that created it, and what the row holds is the final string. That is the design decision rather than a shortcut: composition reads the request's language, its settings, its signed-in user and its unsubscribe token, and a worker running an hour later has none of them. A spool that stored inputs and rendered on delivery would send a different message from the one the caller composed — occasionally, and unreproducibly, which is the worst available failure mode for mail.

It also means an address that opted out is never queued. Suppression happens at the same moment send() would have checked it, against the records that request could see.

queue() returns whether the message was accepted for delivery — a weaker claim than send()'s delivered, and the reason it is a separate method rather than a flag on send(). No existing caller's understanding of its own return value changes.

The two answers a mail server gives

The same discrimination the push channel makes, for the same reason. A 5xx is «never» — no such mailbox, rejected for policy — and fails the row at once with the reason on it. A 4xx, a DNS failure, a timeout, a refused connection are all «not now», and leave it pending.

Treating the first as retryable spends a full SMTP connection every run on an address that will never accept the message. Treating the second as fatal discards a message because a mail server had a bad minute — and that is the invisible failure, because a row marked failed looks exactly like one that was genuinely undeliverable.

Anything with no recognisable code is treated as temporary, which is the safe direction: being wrong costs one more attempt, against silently losing a message that would have gone.

No attempt counter, and that is deliberate

A real MTA retries for days and then bounces, because the useful question is whether something has been undeliverable long enough to stop — not how many times it was tried. So the bound is time: mail.outbox.deadline, 24 hours by default. Past it the row fails with the reason, and is not attempted again.

This is also why no column was added. The table as it stands is enough.

What defers and what does not

queueable(): bool on a notification, read by MailChannel through method_exists() like every other optional declaration a notification may make. Declaring nothing keeps today's behaviour exactly, which is the right default:

A second-factor code somebody is watching for it — never
A new-device sign-in link same
An operator pressing Send they are entitled to be told what happened
A security alert, an audit notice nobody is waiting — queued

NewSignInNotification and SecurityChangeNotification now declare it. The framework's second-factor and auth-link notifications deliberately do not. Only mail defers: the database channel is one INSERT, and push already batches every subscription into a single flush.

Two smaller decisions worth recording

recordMail(bool $success) kept its signature. It is documented as overridable for custom logging, and widening it to take a status would have broken those silently — PHP ignores an extra argument to a userland method, so a subclass would have gone on recording 1 for a message that was only queued, and the worker would never have found the row it was told to send. The widened one is writeMailRow(int $status), and the outbox calls that.

Rows are marked after the send, not before, and the command holds a worker lock. The lock is what stops two overlapping runs both sending the same rows. Marking afterwards means a crash mid-run resends at worst one message rather than losing it — for a notification that says somebody signed in to your account, that is the right way round.

What this is not

It is not a queued Notifier. That would need a serialisable-notification contract — «give me your constructor arguments as scalars and rebuild yourself from them» — and a class that changes in a deploy leaves a queue full of messages that cannot be reconstructed. Nothing here serialises an object: what is stored is a rendered message, which is four strings and a timestamp.

And it is not the body store. That was built on 31 August and reverted the same day pending a design; content holds the body today, so the outbox works with the table exactly as it is and unblocks none of those decisions.

The repository history was rewritten

Code comments and guides carried the names of private projects — as the provenance of a bug report, or as the namespace in an example. Provenance is worth keeping and the names were not: a reader outside those projects learns nothing from a name and everything from what happened, so each one is now described by its situation. «Reported from a project running four daemons under the orchestrator» says more than a name did.

The names were in the history as well as the working tree, so the history was rewritten. Every commit SHA changed.

If you have a clone:

git fetch --all
git reset --hard origin/main

git pull will report unrelated histories — the new history is not a descendant of the old one. Branches and the 1.0, 1.1 and 1.2 tags were all rewritten and force-pushed; a clone that keeps an old branch keeps the old objects with it.

Commit hashes quoted in these posts were retargeted to the rewritten SHAs rather than removed, so the links still resolve. That was done through the rewrite's own old→new map and not by pattern — several things in these posts that look like short hashes are timestamps, byte counts and request identifiers, and a regex over hex would have rewritten those too.

2 September 2026

57 changes:

  • A caller of OutboundUrl::fetch() could not tell a 404 from a 200 — and the framework's own caller was storing the placeholder
  • The tokenactions self-repair could not run on MySQL
  • What the email second factor does when the store or the mailer fails
  • The class added this morning was the day's biggest coverage gap — and addRemoteImage() with it
  • Who counts as signed in for the administration area, and what a missing page answers with
  • A grouped page could not be asked for at all
  • Where the write spool buffers, and how long it keeps trying
  • Re-running create:model destroyed the model
  • The Redis operations that only Redis has
  • …and the same reconnect on the path most statements take
  • The Select2 branch of the CRUD generator
  • Revoking one device, and the cascade that is MySQL-only
  • What each DevPanel card shows when the thing behind it is missing
  • A security-notification class at 6% covered
  • The per-address rate limit had never run
  • The session upsert had never been issued against PostgreSQL
  • What a client is told about every MCP tool before it calls one
  • A round of 69 tests that moved coverage by one statement
  • route-list was tested through its parser and never through itself
  • The two scheduled commands whose execute() had no test at all
  • The scaffolders' refusals, and two things that cannot be tested the obvious way
  • MediaObject's error arms, and a dead-code finding that was not one
  • An application's own User class was never once returned in a test
  • Losing Redis would have taken the application down, not the cache
  • The Memcached counter had never run, and clear() empties more than you think
  • What the human check does when it breaks, and the top three targets that cannot be reached
  • The Greek in the search box had never been tested
  • The URL cache that keeps a worker's memory bounded
  • ST_MakePoint() takes longitude first, and nothing had ever checked
  • The deadlock retry, which had never retried anything
  • The privilege boundary in the admin area, and a green run that proved nothing
  • A better index: 48 methods with no covered line at all
  • TOTP replay protection, which had never run and stands down three ways
  • The webhook credential check, and the fourth test that replaced what it tested
  • The second leg of an API login had never been taken
  • The account a client-credentials token hangs on, and a one-character invariant
  • --spa-components and the two conditions that silently decide nothing happens
  • 453 statements were being excluded from measurement by accident
  • Three small classes off the never-run list, and a test that told me it was empty
  • What auth:twofactor-cleanup actually sweeps, and the assertion total I made unreadable
  • A test called "the omnibox limit is capped" that would pass on an uncapped omnibox
  • Configuring a local asset list crashed every page build
  • An addon setting called 2fa_enabled is not called that
  • The query behind hasIndex(), and the assertion that PostgreSQL does not inherit it
  • Three counters, sessions and headers — and a setter that means the opposite of its name
  • Seven more off the never-run list, in one pass
  • Three more, and a state leak I caused and had already written the guide entry for
  • Three small ones to close the gap
  • The machine account, an MCP call's identity, and a helper the framework ships untested
  • /adminer refuses with a 404, and why that is the security decision
  • The reconnect that must forget, and the first sign-in that is not a new device
  • The middleware list, and the bracket somebody will forget
  • can() and cannot() — the pair every guard clause is written with
  • The last three: two seams and the accessor every closing test reads
  • The two gaps that were in the environment, not the tests

1 September 2026

65 changes:

  • The exit code a scheduler reads from spool:drain
  • The rest of the Adminer route, and a log line read back rather than copied
  • One broken job does not stop the work pass
  • The refusal in auth:unlock
  • …and the rest of the day, below