Skip to content

Email

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.

29 August 2026

27 changes:

  • Two invisible columns, and a row of chips that ran off the panel
  • Reading a sent message back: what it carried, and what came of it
  • Web push: a notification on a device whose browser is closed
  • The coverage gate reported 100% on a change that added an untested package
  • Sending one account a message, on the channels it can actually receive
  • Mass messages: an audience worth choosing, and push that reaches people
  • The line the inbox shows, dark mode, and what DNS says about your mail
  • Three MCP tools for the questions that were being answered by failing
  • A log viewer in the DevPanel, at the address that used to 400
  • The mail log grows without limit, and deleting it is the wrong fix
  • The drift check's own false alarms, and the panel that had never worked
  • The email preview's findings were taking a third of the width from the message
  • Push had five parts and shipped four of them
  • Every browser restart minted a session, and none of the old ones ever ended
  • Nothing knew what kinds of mail an application sends
  • Choosing who a mass message goes to, and seeing who that is
  • The soft prompt was rendered on every page and never shown
  • Nothing recorded a sent push
  • The DevPanel had a second log viewer because of one hard-coded URL
  • The database tab could not answer a developer's question about a database
  • An unread message was something you had to go looking for
  • Two development query logs that grew until the process died
  • The database tab, again: chunks, and the half of the admin screen it still lacked
  • Twenty-five forked PHP processes, for a constant
  • The database tab, third time: copied rather than rewritten
  • An unsubscribe is two records, and the second had nowhere to go
  • The coverage gate, and the two bugs it found

28 August 2026

66 changes:

  • A sign-in is questioned when it looks wrong, not when the browser is new
  • One TOTP code, one login
  • A password change that cannot be the same password
  • The bundled sign-in forms can price automation
  • The mail an account was sent is on the account's screen
  • Themed email: the column that had never been read
  • An email is written in the recipient's language
  • A datatable over an authserver.* table read as empty on MySQL
  • Authorization is three layers, and the guide said two
  • Two reported bugs: a date of zero, and a cropped PNG's black corners
  • Something runs the second-factor cleanups
  • The services screen says whether anything is listening to its buttons
  • A message to many accounts, composed and sent from a screen
  • Three test classes were spending their time emptying the cache
  • Requiring a second factor, and requiring a real one
  • The debug bar says where the second factor stands — and stops forgetting your tab
  • Three dead ends on the administration screens
  • A settings row no longer opens the debug toolbar
  • Nothing on the settings screen opens the DevPanel any more
  • A scaffolded application comes with something that runs its background work
  • /admin/Services says how to create the supervisor
  • New-sign-in alerts can be on unless turned off
  • The DevPanel's Back button goes where you came from
  • Session can write, not only read
  • One apostrophe no longer destroys a page's breadcrumb structured data
  • Html\Date renders the time and the dropdowns it always claimed to accept
  • The queue worker runs under a supervisor
  • /admin/Services can see a supervisor in another container
  • An unsubscribe link, and the two headers Gmail actually reads
  • Addon::trigerAddon() refuses a nameless addon too
  • The default language is a list, not ten characters of free text
  • Adminer, at /adminer, behind the application's own gate
  • /messages — the inbox those internal messages were going into
  • Any browser with JavaScript can solve the human check — and a test proves it
  • A CSP-blocked redirect, and a script with two nonces
  • Adminer signs itself in
  • Html\Date reads the properties it declares
  • An idle connection is not a query running for three hours
  • Html\Date's field is validated by the browser again
  • A date is written the way the language writes dates
  • Two of the widest columns on the process list said the same thing four times
  • Two empty boxes where the log charts should be
  • The log dashboard's figures, asked for by something that is not a screen
  • The components guide listed Seo and then never mentioned it again
  • mcp:serve had its own copy of the tool catalogue, and it was stale
  • The most frequent error in the log was the framework asking a question
  • Every log entry was dated the moment you looked at it
  • mcp:serve is not something a person could debug
  • An MCP tab in the DevPanel: the schema as a form, the answer on the page
  • find-symbol: the question grep cannot answer
  • The DevPanel's MCP tab shipped with a JavaScript syntax error
  • route-list executed the views, and then said there were no routes
  • A link in the DevPanel is styled wherever it is
  • Two more MCP tools: what the CLI can do, and what the theme is made of
  • Three tests that were a copy of the tool catalogue
  • api-docs and find-tests: the other two of the four
  • Two rules that could not be checked, and now can
  • changelog-add: the one tool that writes
  • The plain-text part of an email was the CSS, with the links removed
  • Four headers that decide what happens to a message
  • Gmail actions: a button in the message list, and the reason yours is not showing
  • A ViewAction never needed a handler — the password-reset mail has one now
  • One-click mail actions, and the handler a "this wasn't me" button needs
  • The unsubscribe page was 181 KB, and 180 of them were the website
  • A session count that was not a number, and four tables called sessions
  • Email tracking that works, and only for mail somebody agreed to receive