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.
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:
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
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.
The related trap, which is not a bug today
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.
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 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.