Skip to content

23 September 2026

17 changes:

  • Pramnos\User\User was not notifiable, and the guide said it was
  • Scheduled tasks can run on one server instead of on each of them
  • Sessions can be put in a shared store without touching php.ini
  • …and health:check says when they are not
  • The session store setting was never read
  • Pramnos\Storage was unreachable, and that is why nobody used it
  • The media system can publish to a storage disk
  • A generated detail view renders a picture, and a guard keeps it asking for the address
  • A guide for taking a monolith to more than one server
  • Three of the four things that guide said were not solved, are
  • The scaffolded test runner reported success on a red suite
  • A scheduled command that failed recorded its status and not its reason
  • The deploy webhook did its work before it answered, so a slow push was dropped
  • A middleware could not decide what a request is, because init() already had
  • session_tracking => false was read by one of the two places that run the tracker
  • A password toggle rendered inside an attribute broke three screens, and a guard found two
  • A check that said it must not page for a correct installation paged for one

Pramnos\User\User was not notifiable, and the guide said it was

The Notifications Guide has always said:

Pramnos\User\User already is one. The trait gives you notify() and the default routing below.

It was not one. The class implemented neither NotifiableInterface nor NotifiableTrait, so the guide's whole worked example ended in Call to undefined method Pramnos\User\User::notify() — the first line anybody writes from that page, and the first that failed.

Why it went unreported for so long is where it fails. A notification is sent from a scheduled pass, and a fatal in a scheduled pass is a silent nothing: the daemon logs it and the next tick runs. There is no red screen and nobody is waiting on the response.

Everything underneath was already built for this. The trait's default routing wants $this->email for mail and $this->userid for database, and User has both; DatabaseChannel already falls back to $notifiable->userid. Every channel was expecting this shape. Only the entry point was missing.

Fixed

  • User implements NotifiableInterface and uses NotifiableTrait. The default routing is correct for it unchanged, which is the argument for the trait rather than a hand-written notify() — and there is a test pinning that, so if it stops being true the trait stops being the right default and something says so.
  • The guide's sentence is true now, and carries a note saying when it was not and what the workaround was — (new Notifier())->sendNow($user, $n) is the same path one line lower down and can go.

Added

  • UserIsNotifiableTest. The assertion that matters is not the routing — a test of that would have passed throughout, because the routing was always right — but that notify() reaches a channel. Verified by removing the trait and watching it come back as Call to undefined method.
  • The channel is named by its fully-qualified class name rather than by an alias, because that is the only thing that works through notify(): the trait constructs its own Notifier, so an alias registered on any other instance does not exist as far as that call is concerned. The framework's own error message says exactly this, and the test takes the path it points at.

Documentation

  • A row in the Upgrade Guide, and User::notify() / routeNotificationFor() added to the new-methods row from the day before — Pramnos\User\User is a class applications are encouraged to extend, so a user class with its own notify() is a fatal at class load rather than a runtime surprise.

Scheduled tasks can run on one server instead of on each of them

withoutOverlapping() takes a WorkerLock, which is a file — and the class says so: it records the holder's host and checks the holder's pid on the same host. On one machine that is right and free.

On two web servers each has its own var/ and its own sys_get_temp_dir(), so each takes its own lock and every scheduled task runs once per node. Two copies of a nightly email, two of a billing run. Nothing fails, which is why it can run for months unnoticed — and why the only remedy was an operational rule somebody has to remember: cron on exactly one machine.

Added

  • Scheduler::command('reports:nightly')->dailyAt('02:00')->onOneServer(). It takes a SharedLock — a row in pramnos.locks whose primary key is the exclusion. An INSERT of a name already present is refused by the engine, not by a check the class makes, so there is no window between looking and taking.
  • It composes with withoutOverlapping(): that one stops a slow run on this machine starting again, this one keeps the other machines out. A task that wants both says both.

The database rather than the cache

A shared cache is the usual answer to this, and it is optional here. The default adapter is local files — which would be a lock that excludes nothing in exactly the same way, and just as quietly. A lock that works only when somebody remembered to configure Redis is worse than no lock, because it looks like one. Every installation has a database.

Three things the tests settled

  • Takeover is delete-then-insert, not a guarded UPDATE. The guarded update is atomic, but knowing whether it matched means reading the affected-row count — and QueryBuilder::update() hands back a Result whose numRows came back null for an UPDATE that had plainly worked. Read as a boolean that would make every takeover "succeed", including ones that matched nothing: two holders of a lock that exists to prevent exactly that. Measured, not assumed. So the delete clears an expired lease and the insert stays the atomic step — one mechanism rather than two.
  • The owner is host:pid:token, not host:pid. release() is scoped to the owner so an evicted holder cannot delete the lock its successor is working under — and host:pid does not separate two locks in one process, including a process that took the lock, lost the lease and took it again. The test for that failure is what found it.
  • A closure has to be named. describeHandler() answers the constant Closure for every closure, so two unrelated closure tasks would share one lock and take turns not running — the silent shape this whole feature removes. onOneServer() raises instead: ->onOneServer(name: 'metrics:rollup').

SharedLock is at 100% line coverage, including what it does when the database is unreachable: it does not hand out the lock. Answering "free" because nobody can be shown to hold it would let every node run at once, which is the opposite of the point.

Sessions can be put in a shared store without touching php.ini

PHP keeps sessions in local files unless told otherwise. On one server that is correct and free; on two it is the quietest failure in a deployment. A visitor whose next request lands on the other node has no session, so they are signed out at random on a site that is otherwise working — and it reads as an expiry, a cookie problem, a SameSite mistake. Everything except a load balancer.

The framework said nothing about it, and the remedies were sticky sessions at the balancer or a php.ini a deployment may not control.

// app/config/app.php
'session' => ['handler' => 'redis'],

APP_SESSION_HANDLER and APP_SESSION_PATH in the environment win over it, so a deployment can switch stores without editing a committed file.

The handler is settable at run time as long as it is set before session_start() — which is the window Session::start() already runs in, next to use_strict_mode and for the same reason. Verified rather than assumed: session_module_name('redis') returns the previous handler, and the session lands in Redis under PHPREDIS_SESSION:<id>.

With no path, the cache's host is reused

An application that configured Redis for its cache has already said where Redis is. A second copy of a hostname is a second thing to get wrong — silently, because a wrong session host signs people out rather than erroring. Give 'path' explicitly when the two are genuinely different servers.

It never fails the request

A handler PHP has not registered, a store that is down, a misspelling — all of them leave sessions on files and log why. A working single server is a better failure than a site that will not boot, and the check below is what stops the fallback being silent.

…and health:check says when they are not

  • session_storage — degraded on files, naming the store either way, because the common way to get this wrong is not leaving it on files but pointing it at a Redis the other nodes do not use. It cannot see sticky sessions at a load balancer, so on that arrangement it stays yellow deliberately.
  • The save path is redacted past the ?. tcp://redis:6379?auth=hunter2 is a valid Redis save path and health:check output is pasted into tickets.

Writing the test for it turned up the reason the seam exists: session.save_handler cannot be changed once a session is active, so a test driving it through ini_set() passed alone and failed in a full run, where something earlier had started one. The check takes its two values through the constructor now — the same seam CacheBackendCheck takes its cache through, for the same reason — and there is a test that with nothing injected it still reads the live settings, because a seam nobody exercises from the real path reports on nothing.

Documentation

  • A new section in the Workers and Daemons guide: what is already shared across servers (the queue claims jobs with FOR UPDATE SKIP LOCKED, the WebSocket server has a Redis backplane, ClientIpResolver knows about proxies), what is not and how each one fails.

The honest remaining gap is uploads: www/uploads/ is local disk and there is no object-storage layer, so a picture uploaded on one node does not exist on the other. Shared storage is the answer today.

The session store setting was never read

Shipped an hour earlier in this same day's work, and it did not work.

Settings::getSetting() casts an array setting to a stdClass on the way out. The code read it with is_array(), which is false for an object — so 'session' => ['handler' => 'redis'] in app.php read as no configuration at all and sessions stayed on local files. Silently, on the one feature whose entire purpose is to stop a silent single-server assumption.

Cache has (array) Settings::getSetting('cache') and has had it for years. Not reading how the neighbour does it was the mistake; the cast was one line away.

Fixed

  • Both reads cast, and SessionStoreConfigTest covers them: a configured handler is seen, the environment wins over the file, and the path falls back to the cache's host. Verified by removing the cast and watching the first one fail.
  • sessionPathFromCache('memcached') has no tcp:// scheme, which is memcached's own format rather than an oversight — a scheme there is not parsed and the store is silently not used.

Pramnos\Storage was unreachable, and that is why nobody used it

The framework has shipped a storage abstraction since v1.2: StorageManager, a Storage façade proxying twenty methods, and local, s3 and ftp drivers — at 93–100% line coverage, across 83 tests.

Nothing in src/ uses it. Not one reference outside its own namespace.

The reason is one line:

throw new \RuntimeException(
    'Storage has not been initialised. Call Storage::init($config) during bootstrap.'
);

init() is never called by the framework, and never by anything it scaffolds. So the façade answered with an exception for every application that had not already read the class and wired it up — and an application reads the class only after it knows the class is there. A well-built, well-tested subsystem with no way in.

Fixed

  • Storage::getManager() builds itself from the storage block of app/config/app.php, and falls back to one local disk rooted at www/ when there is none — where uploads already are, so nothing moves and nothing has to be configured for the façade to work. That is the whole of "optional": available without being imposed.
  • The settings are cast on the way in, nested disks included. Settings::getSetting() hands back a stdClass for an array setting, and reading it with is_array() is how a configured block silently reads as none — the same mistake as the session store above, in the same day.
  • init() still works and still wins, for an application that would rather configure in code.

Documentation

  • The Workers and Daemons guide now names Pramnos\Storage as the alternative to a shared mount, with a worked s3 disk and the note that aws/aws-sdk-php is the application's dependency rather than the framework's.
  • And a warning that the media system does not use it yet: MediaObject still writes to www/uploads/ directly — twenty filesystem calls, and GD writing with imagejpeg($image, $path), which needs a real path a bucket does not have. Configuring a disk today gives an application somewhere to put its own files; it does not move the framework's uploads. A shared mount is still the answer for media on more than one server.

What this cost to find

Two false starts, both the same mistake. ls src/Pramnos/ truncated at thirty entries and Storage was below the fold, so a whole parallel implementation got written — interface, local driver, S3 driver with hand-rolled SigV4 — before the coverage report named files that should not have existed. Then the tests: tests/Unit/Storage/ is empty and tests/Unit/Pramnos/Storage/ holds all 83, so "this subsystem has no tests" was said out loud and was wrong.

Nothing wrong reached a commit, and the SigV4 work was thrown away rather than kept beside an SDK that already does it. The rule it breaks is the oldest one here: look at what is there before writing what is next.

The media system can publish to a storage disk

The other half of the one above. Pramnos\Storage is reachable now; this is what uses it.

Name a disk media and uploads go to it. Name it anything else, or none at all, and nothing changes — the file stays in www/uploads/, served by the web server exactly as before, and nothing is copied. Every existing installation is that case.

A disk name rather than a new setting, because a name is discoverable — somebody reading app.php sees what it is for — and one concept is cheaper than two.

The work stays local; only the result is published

GD writes with imagejpeg($image, $path), a real filesystem path a bucket does not have. So the resize pipeline is untouched: upload, resize and thumbnail locally, then publish what came out. A stream wrapper would have hidden that difference until it surfaced as a warning from inside GD.

The local copies stay. They are the origin a later resize reads, and on a single server they are also what is served — deleting them would make rendering a page depend on the disk being reachable, which is a worse failure than the one this is for.

$media->url was already the key: the path relative to www/, uploads/2026/09/x.png, which the media system has stored since long before there was a disk. Nothing about the database changes.

Added

  • publishToStorage(), called after save() in uploadFile() — after, because the row is the record and the disk is a copy of it, and a publish that fails must not lose an upload the application has accepted.
  • republishToStorage(), which puts what is missing and skips what is there, so a backfill over ten thousand pictures is cheap to run twice.
  • deleteFromStorage(), wired into delete(). A row whose files are gone locally and present on the disk is the worse half of the two: the disk is what a CDN reads and keeps serving.
  • publicUrl($reason) — the disk's address when there is one, the site's own when there is not. A view concatenating sURL with the stored path keeps working and keeps pointing at the server the file may no longer be on.

The catch is \Exception, not \Throwable, and a mutation is why

Publishing absorbs failures: a disk that is down does not fail an upload already accepted. Written with catch (\Throwable) that absorbed too much — deleting the null-disk guard left every test green, because Call to a member function put() on null was swallowed and publishToStorage() returned 0, which is also what "no disk configured" returns.

Two indistinguishable answers, one of them a bug. \Exception is a disk that will not write; \Error is a fault in this code, and it belongs in the open. With the narrower catch the same mutation fails immediately.

Added tests

Ten, and the ones that matter most are the without a disk half: nothing published, nothing copied, and publicUrl() unchanged. A feature that is optional in the docblock and not in the code is not optional.

Also pinned: a rendition whose filename is the original — reason = 'original' — is published once, because the files are keyed by url rather than by local path. Keyed the other way it would copy the same bytes twice and try to delete the same key twice.

A generated detail view renders a picture, and a guard keeps it asking for the address

The media-to-storage work earlier today ended with "in views, use publicUrl()" — and the question that followed was the right one: had the views been fixed?

There were none to fix. Every <img> the framework ships points at a QR data-URI, a brand logo string, application->logourl or user->avatar, and not one of them goes through MediaObject. The two grep hits for sURL . 'uploads/' are both comments — the ones written to explain why that line is wrong. The media system is used only by applications, which is why the Upgrade Guide row addresses them.

That answer is also the problem: a framework that emits no media URL has nowhere to set the example, and no guard can forbid a shape that does not occur.

Added

  • MediaObject::urlFor($mediaid, $reason) — the one line a view needs. An id that is zero, null or gone answers '', because a row can outlive its picture and an empty src is the caller's decision rather than a broken image the framework invented.
  • create:crud renders a picture in the detail view of all three themes, for columns whose foreign key references media. From the keys rather than from column names: a foreign key to media is the framework's statement that a column holds a picture, where a name-based rule would catch a column somebody called logo and meant as a word, and miss header_id.
  • A table with no such column emits [], which is most tables.

The guard

GeneratedMediaUrlsTest forbids sURL . $x->url and sURL . 'uploads in any stub, and — the half that matters more — asserts the detail view does call urlFor(). A sweep that only forbids the wrong thing passes perfectly over generated code that renders no pictures at all, which is what it did until this change.

It is narrow on purpose: sURL . 'Things/edit/' is a link to a page of this application and is exactly what sURL is for. What is forbidden is joining it to a stored file path.

First run flagged three files — all three of them the guard's own explanation, which quotes the forbidden line in order to explain it. Comments are stripped now. That is the second guard in this repository to report its own documentation on the day it was written.

A guide for taking a monolith to more than one server

Pramnos_Multi_Server_Guide.md — the checklist, in the order the failures actually arrive, with how to verify each rather than how to hope.

It opens with what already works and needs nothing, because that is most of the system and a guide that starts with a list of problems misrepresents it: the queue claims jobs with FOR UPDATE SKIP LOCKED, the WebSocket server has a Redis backplane with periodic reconciliation, ClientIpResolver reads the proxy chain.

Then the six that do need something — sessions, cache, scheduled tasks, migrate, uploads, APP_URL — each with its symptom first, because that is how somebody arrives at the page. Sessions read as an expiry. Cache reads as a page that is right on one refresh and stale on the next. Scheduled tasks do not read as anything at all, which is why they run doubled for months.

Four of the six report themselves in health:check, and the guide says to run it first and last — and on each node, because a check that is green on the machine you deployed from says nothing about the other one's filesystem.

And what the framework does not solve

Stated plainly at the end, because a guide that only lists what works is not a guide: local logs, var/cache/, deploy consistency between nodes, and the database — one connection, no replication or read splitting. A shared mount is named as the right answer for uploads wherever it is available, ahead of the object storage the framework now supports.

Three of the four things that guide said were not solved, are

The multi-server guide above ended with a short "what this framework does not solve for you" — local logs, var/cache/ and the file spool, deploy consistency, and the database, where it said "replication, failover and read splitting are out of scope; the framework talks to one connection".

Three of those four were wrong, and the last one was wrong in the way that matters most: read/write splitting has shipped since v1.2. Database takes a read and a write block, routes every statement by its first keyword, and keeps the two links as separate sessions with their own time zone and their own reconnect. The guide told a reader to go and solve, outside the framework, something the framework does.

It could say that because the feature was documented only in 1.2-new-features.md — the frozen v1.2 reference — and in a DevPanel table. No guide owned it, so nothing described it as current behaviour, which is the repository's own documentation rule failing in the exact shape rule 1 describes: a feature that is present, works, and reads as absent.

Fixed

  • Pramnos_Database_API_Guide.md now owns read/write replicas — configuration for both roles, which keywords pick which link (SELECT, SHOW, EXPLAIN, DESC, DESCRIBE read, everything else writes, so an unrecognised statement reaches the primary rather than a read-only replica), and the three things to know before turning it on: replica lag against reading your own write, that two hostnames are configuration rather than failover, and that an installation declaring neither block gets exactly what it has today.
  • Pramnos_Multi_Server_Guide.md replaces its closing section with "the three that look like gaps and are not":
    • Logs — PRAMNOS_LOG_MODE=both keeps the file for LogViewer and sends the same line to STDERR for whatever collects it. Per process, so a node can differ without a deployment.
    • var/cache/ and the write spool — both have a Redis driver, and neither announces that it is not using it. CacheBackendCheck reports the running backend against the configured one, which is the check to watch before a second node goes live.
    • Database — the section above.

What remains under "does not solve" is one item: nothing here can tell you that two nodes are running the same commit, because each node answers correctly for the code it has.

The scaffolded test runner reported success on a red suite

./dockertest; echo $? printed 0 over a screen that said FAILURES! Tests: 1799 … Failures: 1.

The script ends with an if that opens the coverage report, and an if whose condition is false exits 0. PHPUnit's status was never captured, so the script's status was the browser block's.

Everything that reads the summary was fine, and nothing else was. A && chain, a git hook, a CI step and an agent checking its own work before committing all ask the same question the same way, and every one of them was told a failing suite was green. A project on this scaffold shipped a commit that way — which is how it was found, and reported back.

Fixed

  • Init writes phpunit_status=$? after each of the three PHPUnit branches and exit $phpunit_status as the script's last statement. The capture is on the line immediately after the call because $? is the previous command's status — an echo in between silently replaces the answer.
  • InitDockertestExitStatusTest asserts the invariant structurally rather than by running the script, which would need Docker: every branch that invokes PHPUnit captures $? on the very next line, and the last statement is the captured status. It holds the framework's own runner to the same rule, because fixing one copy and not the other is how a runner bug comes back — the next project is generated from whichever one was missed.
  • The count assertion in that test is the part worth keeping: without it, a script that stopped mentioning PHPUnit at all would satisfy every other assertion in the file.

Documentation

  • Testing Guide — "./dockertest exits with PHPUnit's status", including the shape of the mistake, since it is easy to reintroduce, and the note that a project scaffolded earlier owns its own copy that version control will not update.

A scheduled command that failed recorded its status and not its reason

schedule.log said:

failed: channels:collect (0 * * * *) — Scheduled command 'channels:collect' exited with status 1

and there was nothing, anywhere, saying why. The command had died twice in a row on a production installation and three of five feeds went unread for two hours; working out the cause took reading application tables to see how far the pass had got and comparing that against the day's deploy times, because the exception message the command printed no longer existed.

Cause. runShellCommand() was passthru(), which writes the child's output to the parent's stdout and keeps none of it — and the parent is schedule:run under cron, whose stdout is /dev/null on every installation this framework's own documentation describes. The status code survived into the log. The sentence explaining it went to a device that discards it.

That is the wrong half of three to keep. The log was a good record of what ran and of whether it worked, and no record at all of why it did not — which is the only one of the three anybody ever needs urgently.

Fixed

  • runShellCommand() reads the command through a pipe with 2>&1, echoes each chunk on as it arrives, and keeps the last ScheduledTask::OUTPUT_TAIL (2 KB). execute() appends it to the RuntimeException it already threw, so schedule:run, work and schedule.log carry it without changing.
  • The tail, not the whole output — schedule.log is appended to on every tick, so a task printing a megabyte would otherwise fill it. The tail, not the head — a chatty command's progress output is at the top and its error is at the bottom.
  • 2>&1 is the part that makes it useful, since the reason is on stderr nearly every time. The cost is that an interactive schedule:run sees the two streams merged.
  • The captured output is null until something captures it, rather than ''. runShellCommand() is protected, so an application may have overridden it, and an override predating this records nothing — null distinguishes "nobody captured anything" from "it printed nothing", which is the difference between the old message and a message ending in a bare Output:. ScheduledCommandFailureReasonTest asserts that case directly.
  • Those tests run real shell commands through the real seam. Every other test in this area overrides runShellCommand() to assert what would be run, which is exactly how four statements that discarded the output went unexamined for as long as they did.

Documentation

  • Workers & Daemons Guide — what a failed: line looks like now, and why the framework's log is the last place the reason exists rather than one copy of several.
  • Upgrade Guide — a row, since the message shape changed and an interactive run's stderr moved.

The deploy webhook did its work before it answered, so a slow push was dropped

Two pushes in a row did not reach production. GitHub recorded 500 … context deadline exceeded after exactly ten seconds; the server never finished git fetch --all, so origin/main on production was still the previous commit.

Nothing on the installation said anything. The application was healthy, every check was green, and it was serving code from two commits ago while git log on the developer's machine said it had shipped. The only record was a red row in a webhook delivery list nobody opens.

Cause. handle() ran the commands and then wrote the response. GitHub allows a webhook ten seconds in total and does not retry. The deploy — fetch, reset, composer install, migrate, cache:clear — takes four to six of them. Measured from a shell, composer was 0.3 s and the fetch 2 s, so nothing was wrong with the commands: there was simply no margin, and the margin being spent belongs to somebody else's network.

The second half is what made it expensive. The provider gives up after one attempt, and the aborted request does not finish the work either — so the deploy is dropped and nothing is left behind saying so.

Fixed

  • handle() sends 202 Accepted as soon as the signature, the event and the branch check out — everything the response can honestly say is known by then — and runs the commands afterwards. ignore_user_abort() and set_time_limit(0) first, so a caller that has already timed out does not take the deploy with it.
  • Content-Length with the flush, because fastcgi_finish_request() genuinely closes the connection only under PHP-FPM. Elsewhere the body still reaches the wire before composer install starts, which is most of the benefit; the guide says so rather than implying the problem is solved everywhere.
  • What it costs is stated, not buried. The response can no longer carry an outcome that has not happened, so the delivery list is green either way and webhook.log is where the result lives. respondFirst(false) keeps the old contract for a webhook whose caller genuinely reads it, and there is a test asserting that path still works.
  • The ordering is asserted end to end rather than by inspection: the flush and the deploy command append to the same file, and the test reads it back. A fix that answered early and dropped the work would satisfy every other assertion.

Documentation

  • A new guide, "Deploy webhooks" — WebhookHandler was documented only in 1.2-new-features.md, which is frozen, so no page described it as current behaviour. That is the second time today the same gap has turned up; this one is closed the same way, with the ten-second rule as the section a reader most needs.
  • Upgrade Guide — a row, because the response code and body changed shape.

A middleware could not decide what a request is, because init() already had

A middleware calling $request->setController('bio')->setAction('geekdom') had no effect. The application dispatched the controller it had worked out at boot, and the middleware's answer went to an object nothing read again.

The entry point is init(), then the pipeline, then $pipeline->run($request, fn() => $app->exec()). init() copies the controller and action off the URL; the pipeline is downstream of that, so every middleware in it ran after the decision it might want to change. The pipeline wrapped the dispatch, as documented — but only the execution of a decision already taken.

That is most of the reason to have a pipeline. A Host header, a locale prefix, a maintenance switch, an A/B split: each is "what is this request" answered from something other than the path, and none of them worked.

Why nobody found it sooner. Request::$_controller is static, so the setter succeeded and getController() handed the new value straight back. A test asserting the middleware's decision passed — theirs did. The first thing that ever disagreed was a production URL serving the wrong page.

Fixed

  • exec() reads the request again at dispatch and takes the later answer, through Request::getInstance() rather than new Request() — constructing one re-runs calcParams(), which recomputes the controller from the URI and would erase the very decision being collected.
  • A hand-assigned route still wins. init() records what the URL said, and the request is consulted only while the route is still that. $app->controller = 'dashboard' between the two calls says something the URL did not, and keeps it; an explicit exec('reports') likewise. Without that guard this would quietly undo a pattern applications are entitled to use.
  • On a request no middleware touched, the value read at dispatch is the one init() already read, so the assignment is its own no-op. There is a test asserting that, because "changes nothing for everybody else" is the claim most worth checking.
  • ApplicationLateRouteTest asserts what the application dispatches, never what the request reports. Asserting the request is exactly what passed while production was serving the wrong page.

Documentation

  • Framework Guide — "A middleware can decide what the request is", with the table of what wins between init() and exec(), and the reminder to set the action as well as the controller.

session_tracking => false was read by one of the two places that run the tracker

The Page Cache Guide gives two settings together, as the fix for a public page that will not cache:

'session'          => 'lazy',   // no session for a visitor who has none
'session_tracking' => false,    // no tracking cookies either

The first worked. The second was read by Application::bootSessionTracking() alone — the path that registers the tracker when nothing else has. An application that lists SessionTrackingMiddleware in middleware runs it through the pipeline instead, and handle() asked nobody:

public function handle(Request $request, callable $next): mixed
{
    $this->track($request);
    return $next($request);
}

So the setting did nothing for exactly the installations that had wired the tracker on purpose and then wanted it off — and the symptom afterwards is the symptom before: Set-Cookie on every response, nothing stored in the page cache, no CDN caching in front of it, and no error anywhere. Somebody following that page has no way to tell which of the two halves failed.

Fixed

  • Application::sessionTrackingEnabled() is now the single reader of the key, and both places call it: bootSessionTracking(), which had the logic inline, and the middleware's handle(), which had nothing.
  • Absent still means enabled. getSetting()'s own default is false, so reading the key without an explicit null would turn "this application never mentioned it" into "this application declined" — switching tracking off for every installation on upgrade.
  • A middleware that declines to track still passes the request down the pipeline. Declining to track is not declining to serve the page, and there is an assertion on that in every case rather than only the interesting one.
  • The existing SessionOptOutTest probe reproduced the gate instead of calling it, which is how the real one came to be read by only one of the two places that needed it. It calls the method now.

Documentation

  • Framework Guide — the key applies however the middleware got there, with the explicit-middleware case named because that is the one it failed for.
  • Page Cache Guide — the same, next to the pair it recommends, since the two failures look identical from outside.

A password toggle rendered inside an attribute broke three screens, and a guard found two

/admin/Settings → Email printed

" autocomplete="new-password" value="">

as visible text beside an eye icon, and the SMTP password field did not render at all. The one field that screen exists to fill in, on an installation configuring outgoing mail for the first time.

Cause. The toggle's markup was emitted inside an attribute:

<input id="smtp_pass" type="password" name="smtp_pass" class="<?php echo $input; ?>
<?php echo PasswordToggle::render('smtp_pass', '', ''); ?>" autocomplete="new-password" …>

render() returns markup, so the first > in it closes the <input>. Everything after that is text in the document. It reads like a missing "> and is not — the toggle is a sibling of the input, not one of its attributes.

All three themes had it on that field, each in a different attribute: Tailwind inside class, the other two inside value.

…and the sweep written for it found two more screens

PasswordToggleMarkupTest checks it structurally — PHP blocks are flattened first, so a ?> cannot be mistaken for the end of a tag, and a toggle whose nearest < is closer than its nearest > is inside one. It failed immediately on screens nobody had reported:

  • plain-css registration, both password fields, inside style="" — that theme's sign-up form.
  • tailwind change-password, all three fields, inside class="".

Six broken fields on three screens, from one mistake copied between themes. A test that searched for the one known broken string would have found the one known broken string.

The stored password was also being printed back

The same line rendered smtp_pass into value="". It is in ENCRYPTED_SETTINGS — so it is encrypted at rest and was then sitting in clear in the HTML of an admin page: in a proxy cache, in a screenshot, on a shared screen.

Fixed

  • Every toggle closes its tag first. Six fields across four views in three themes.
  • SettingsController::display() sends smtpPassIsSet, a boolean, and blanks the value before the view ever sees it — one place it can go wrong rather than three.
  • save() reads an empty submission as keep what is stored. Without that, an operator changing the SMTP host and saving would silently wipe the password on a form they did not think they were touching.
  • A second assertion forbids any view rendering the stored password back, so the two halves of this line cannot be fixed one at a time.

The new test errored twice in eight runs. Tree::files() had already made the listing safe; the read of a path it returned had not been — file_get_contents() answering false with a warning PHPUnit turns into an error, on a file whose host mtime has not moved in weeks.

Tree::read($path) closes it, and it is the shared fix rather than one sweep's: every sweep lists a directory and then reads each file in it. An empty file is '' and not a failure, which is the distinction the retry depends on — reading the two as the same thing would pause 50 ms on every empty file in a sweep and then raise on one.

And one retry turned out not to be enough. Tree::files() looked twice, on the reasoning that one retry is for a mount that blinked and a loop is a way to tolerate a directory that is genuinely gone. The reasoning holds; the number was wrong. The new sweep walks the whole of scaffolding/ and lost both attempts twice in eight runs — which is a flaky test rather than a rare one, and a flake is indistinguishable from a real failure to whoever reads it next. Three attempts now, for both files() and read(), chosen for the same reason two was: a directory that is actually missing costs 100 ms and then raises. Tree is back at 100% line coverage.

The framework's own ASweepOverNothingDoesNotPassTest caught the second of these tests looping without first asserting it had anything to loop over — which is the guard doing exactly its job, on the test written to be a guard.

Documentation

  • HTML Components Guide — "It is a sibling of the input, never one of its attributes", with the wrong version, since that is what a reader will recognise.
  • Email Guide — what the field does now, and how to actually clear a stored password.
  • Testing Guide — Tree::read() beside files() and matching().
  • Upgrade Guide — a row, for anything that relied on the value round-tripping.

A check that said it must not page for a correct installation paged for one

SessionStorageCheck shipped this morning with the sentence in its own doc-block:

Degraded, not down. A single-server installation is not broken and must not be paged for — most installations are single-server, and a check that cries wolf gets muted.

And degraded is answered as 503. So the check did the one thing it documented itself as avoiding, to the majority case it named: GET /health/check went from 200 to 503 on a production installation with nothing changed about it and nothing wrong with it, and an uptime monitor pointed at that endpoint — which is what it is for, and what the scaffolded README recommends — alerted permanently.

This is a guard being weaker than the sentence above it, in the other direction: the sentence was right and nothing enforced it. A doc-block cannot.

Added — HealthStatus::Notice

"Correct here, and would not be everywhere" is a real category, and it had to borrow degraded. notice appears in the JSON, in the health:check table and on the dashboard badge, and does not change the HTTP code. Severity order is ok < notice < degraded < down, so a real problem beside a notice is still a real problem.

  • HealthStatus::isHealthy() is the one place that decides whether a status pages. There were three — the JSON endpoint, the flattened endpoint, and the console's exit code — and three copies of a policy is how the doc-block came to disagree with the code.
  • An unrecognised status is not healthy: something this version does not understand is not something to call fine.
  • health:check exits 0 on a notice. It runs in deploy steps and in CI, and a status that exists to say "right here" must not fail a build for the same reason it must not answer 503.

The sweep the finding asked for

Every bundled degraded was re-read against one test: is there an installation where this exact answer is the correct one?

  • session_storage on files — yes. Now notice.
  • site_url inferred from the request — no, and it stays degraded. An unset APP_URL means every URL built outside a request — a scheduled email, a queued job, a console command — is wrong or empty, on one server exactly as much as on five. It is a latent defect a web request cannot see, which is the reason that check exists. The reasoning is now in its doc-block so the next sweep does not re-open it.
  • cache falling back, hypertables that are plain tables, a disk at 95%, memory at 90% — all wrong everywhere. Unchanged.

Fixed — and a second thing on the same check

The fix detail said "With no path it reuses the cache host." On a shared host — Virtualmin, cPanel, one Redis and many vhosts — that is advice to move session ids into a store every other site on the machine can read. A session id is an account. It now says to point it at a Redis this application controls, and why; the Health Guide and the multi-server guide say the same.

Documentation

  • Health Guide — the HTTP table, "choosing a status" with the test for notice, and why over-reporting severity is not harmless: a check that pages for a correct installation is a check somebody mutes, and a muted check is the one that was going to tell you about the real thing.
  • Upgrade Guide — a row. Anything parsing the report must tolerate a fourth value: a match on $check['status'] without a default now throws, and status === 'ok' as a health test should become HealthStatus::from($s)->isHealthy().
  • The scaffolded CLAUDE.md says what notice means, since that is the file a monitor gets configured from.