Skip to content

Changelog

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

31 August 2026

34 changes:

  • The half of OAuth discovery that was missing
  • Three components that were nearly accessible
  • The Model Context Protocol, over HTTP, for somebody else's assistant
  • Offering an MCP capability without writing a class
  • A page head that stops claiming things the page never said
  • What the site tells a machine that arrives uninvited
  • A keyboard can get past the navigation, and the consent screen stops phoning out
  • A token outlived the client that issued it, and kept working
  • An error log that was mostly not errors
  • A client secret nobody had to present
  • The SMTP password was readable to anyone who could read the database
  • The webhook signing key and the TOTP seed, likewise
  • A tenant scope that held until you asked for page 1
  • A grant on one record opened the whole collection
  • The session cookie lost its secure flag behind every TLS-terminating proxy
  • The legacy CSRF check compared tokens with ===
  • A LIMIT built by string concatenation
  • addslashes() as an escaping fallback, and as a PHP-literal encoder
  • The README described a framework this is not
  • The schema promised tenant isolation nobody implements
  • Joins on two columns, and aliases that survive resolution
  • Permissions can now be resolved within one organisation
  • Roles had two tables, a permissions screen, and no way to make one
  • The client secret is hashed, and the realtime key moves out of its way
  • A public client can say it is one
  • The roles screen had a menu entry and no address
  • A guard that read a variable that did not exist (FW-047)
  • A pager that could not be told to stop counting (FW-048)
  • Two components no theme could style
  • SchemaBuilder::hasIndex()
  • Tokens are encrypted at rest, and matched on a digest

30 August 2026

5 changes:

  • Twenty-five migrations dated 2020 that were written in 2026
  • A full cache flush never removed a directory
  • deferredwrites moves to pramnos, and the schema builder learns how
  • Web push had no sender in the authentication flow
  • The advanced sign-in rules had no field on any screen

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

27 August 2026

52 changes:

  • A select and a pager that need no form around them
  • Four legacy input classes become one
  • One search box over many entities
  • A model save cost 1358 milliseconds
  • Two flags that keep a migration honest
  • reset() left the page where the next request would find it
  • The form field now uses the controls
  • Nine readers of a stream that can only be read once
  • An API endpoint's status code was untestable
  • A flash that worked once per process
  • A permanent cache entry was deleted by the next sweep
  • The dashboard's JSON endpoints returned a web page too
  • The backup codes a user saved were never the stored ones
  • Adding a member to an organization was impossible
  • Any signed-in account could rewrite the system settings
  • The lockout settings configured nothing
  • The tailwind scaffold theme is a daisyUI theme
  • A view directory you half-own rendered a page shell
  • An argument a method does not declare is dropped silently
  • The datatable search that could not be slowed down, and the columns that were all searched the same way
  • Every page after a sign-in page lost its header
  • Seventy-nine queries had lost the table prefix
  • The Redis cache listing read a key nothing writes
  • Vendored assets that were still remote
  • A page could not call its own API
  • One palette, every UI system
  • The administration area has its own directory
  • Six filings from one project, all small and all silent
  • The palette moves under app/themes/
  • Two guides that had become their own history
  • A generated screen the vanilla stacks could not reach
  • Every breadcrumb in the area pointed at /adminusers
  • URL is the administration area, sURL is the site
  • Organizations had no way to look at one
  • A column filter per column, on the lists that need one
  • What a usertype means, and how an application changes it
  • Row actions are icons, and the row is a link
  • The log viewer's own endpoints 404'd inside its iframe
  • Everything the framework knows about a user, on the user's screen
  • Per-user settings, and permissions edited where the user is
  • A screen for the message templates the framework already had
  • New-sign-in alerts got a site policy
  • A login left the previous session's token valid for a month
  • One definition of the guard every admin screen opens with
  • What each usertype may do, written down and on a screen
  • A correct password was refused, and bcrypt had been dropping characters
  • No language was ever selected
  • The administration area may be in another language than the site
  • A second factor that needs nothing set up in advance
  • A new device can be made to prove something, not just reported
  • A second factor an application can bring its own of
  • Seven account-security switches, every one off until asked for

26 August 2026

19 changes:

  • One set of controllers, two addresses
  • The administrator that could not administer
  • The admin links that left the admin area
  • The logout that revoked nothing
  • A webhook queue with no consumer
  • Three endpoints that had never worked
  • A column you could write and not read
  • Every scaffolded project refused to test itself on macOS
  • The test database that would not copy
  • The recovery path was the crash
  • The HTTP tests that were all testing the home page
  • The discovery document that was not JSON
  • The pages that rendered nothing
  • The scopes the server advertised and refused
  • Five actions that could never be called
  • Four views indexing keys that were never there
  • A password you can set from a shell
  • Two language objects, and themes that could not be found
  • The manifest that synced nothing

25 August 2026

30 changes:

  • The audit log gets a key that fits
  • A delete that holds the table
  • A policy you could create but never change
  • Two answers to "where do the views live?"
  • A model save reaches the browser
  • A socket that received nothing
  • Three tables, because retention is per table
  • A config key that did nothing
  • Compression that made it larger
  • The busiest table, and who calls it
  • A stale stat should not cost you the admin user
  • The page cache could not be switched on, and a hit lost its CSP
  • The scaffolded .gitignore had not kept up with what init writes
  • Every scaffolded .mcp.json named a file that was not there
  • A cache hit was telling the browser whatever PHP had already said
  • /login came with the site header on top of it
  • Every inline script was blocked and the report said "the button does not work"
  • The database password was in three committed files
  • A service worker that refuses to cache HTML
  • "I have just cloned this — what do I have to create by hand?"
  • The policy forbade the worker the framework had just started shipping
  • A browser detector with nothing underneath it
  • "No manifest detected", with the link right there in the source
  • A nonce on a data block buys nothing and costs the cache
  • The discovery controller had no address
  • The health report said ok, and no token worked
  • Three endpoints, three opinions about health
  • Two views that nothing could render
  • One long word came back as an ellipsis
  • The memory_limit raise that was lowering it

24 August 2026

15 changes:

  • Seven filings from a consumer, and the two more they turned up
  • The Svelte generator catches up with the MVC one
  • A flush that cleared nothing, and said it had
  • On PostgreSQL a key was a guess, and a response could not say what it cost
  • The scanner that cost more than it saved
  • A filing against a patched vendor/, and the three real bugs inside it
  • The slash that only broke the routes with placeholders
  • A page cache, and the two things its spec could not know
  • Clearing one cache category cost the whole database
  • Two bodies, one URL, and no Vary
  • A model that says what it changed
  • A commit that says so
  • A save that announces itself
  • A bypass that only stopped half of it
  • An application can now decline a session

20 August 2026

14 changes:

  • A default that answered for the configuration
  • A fragment that was written twice
  • An error message from a different statement
  • A dashboard that reported empty
  • A red suite that exited zero
  • A schedule nobody was running
  • A scheduled command that reported "Done"
  • A pid that belonged to another container
  • Rows that could never be written
  • A default that named a store nobody installed
  • A table that only grew
  • A stop request with no deadline
  • A report of zeroes
  • A session that nobody is in, and a report that ranked the unmeasured first

18 August 2026

7 changes:

  • The last twelve were choices
  • A linter for the one asset that ships
  • A JSON reply that did not stop
  • A placeholder that ate the query string
  • Every page answered 404 to HEAD
  • A supervisor that could not tell a corpse from a worker
  • A pid answers a question about your own process table

17 August 2026

9 changes:

  • Two seams that were already half there
  • The docs shipped in vendor/ and nothing ever offered them
  • A correct header, a correct footer, and nothing between them
  • A guard for a null that could not happen
  • A stale @todo, and two classes that do not exist
  • Being told no
  • The suite is finished, and the answer to the last question changed
  • A form class that was never ported
  • Sixty-seven messages nobody could see