Skip to content

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.