23 September 2026¶
17 changes:
Pramnos\User\Userwas 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:checksays when they are not - The session store setting was never read
Pramnos\Storagewas 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 => falsewas 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\Useralready is one. The trait gives younotify()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¶
UserimplementsNotifiableInterfaceand usesNotifiableTrait. The default routing is correct for it unchanged, which is the argument for the trait rather than a hand-writtennotify()— 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 thatnotify()reaches a channel. Verified by removing the trait and watching it come back asCall 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 ownNotifier, 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\Useris a class applications are encouraged to extend, so a user class with its ownnotify()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 aSharedLock— a row inpramnos.lockswhose primary key is the exclusion. AnINSERTof 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 — andQueryBuilder::update()hands back aResultwhosenumRowscame back null for anUPDATEthat 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, nothost:pid.release()is scoped to the owner so an evicted holder cannot delete the lock its successor is working under — andhost:piddoes 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 constantClosurefor 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_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—degradedonfiles, 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=hunter2is a valid Redis save path andhealth:checkoutput 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,ClientIpResolverknows 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
SessionStoreConfigTestcovers 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 notcp://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 thestorageblock ofapp/config/app.php, and falls back to onelocaldisk rooted atwww/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
disksincluded.Settings::getSetting()hands back astdClassfor an array setting, and reading it withis_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\Storageas the alternative to a shared mount, with a workeds3disk and the note thataws/aws-sdk-phpis the application's dependency rather than the framework's. - And a warning that the media system does not use it yet:
MediaObjectstill writes towww/uploads/directly — twenty filesystem calls, and GD writing withimagejpeg($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 aftersave()inuploadFile()— 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 intodelete(). 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 concatenatingsURLwith 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 emptysrcis the caller's decision rather than a broken image the framework invented.create:crudrenders a picture in the detail view of all three themes, for columns whose foreign key referencesmedia. From the keys rather than from column names: a foreign key tomediais the framework's statement that a column holds a picture, where a name-based rule would catch a column somebody calledlogoand meant as a word, and missheader_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.mdnow owns read/write replicas — configuration for both roles, which keywords pick which link (SELECT,SHOW,EXPLAIN,DESC,DESCRIBEread, 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.mdreplaces its closing section with "the three that look like gaps and are not":- Logs —
PRAMNOS_LOG_MODE=bothkeeps the file forLogViewerand 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.CacheBackendCheckreports the running backend against the configured one, which is the check to watch before a second node goes live.- Database — the section above.
- Logs —
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¶
Initwritesphpunit_status=$?after each of the three PHPUnit branches andexit $phpunit_statusas the script's last statement. The capture is on the line immediately after the call because$?is the previous command's status — anechoin between silently replaces the answer.InitDockertestExitStatusTestasserts 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 — "
./dockertestexits 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:
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 with2>&1, echoes each chunk on as it arrives, and keeps the lastScheduledTask::OUTPUT_TAIL(2 KB).execute()appends it to theRuntimeExceptionit already threw, soschedule:run,workandschedule.logcarry it without changing.- The tail, not the whole output —
schedule.logis 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>&1is the part that makes it useful, since the reason is on stderr nearly every time. The cost is that an interactiveschedule:runsees the two streams merged.- The captured output is
nulluntil something captures it, rather than''.runShellCommand()isprotected, 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 bareOutput:.ScheduledCommandFailureReasonTestasserts 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()sends202 Acceptedas 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()andset_time_limit(0)first, so a caller that has already timed out does not take the deploy with it.Content-Lengthwith the flush, becausefastcgi_finish_request()genuinely closes the connection only under PHP-FPM. Elsewhere the body still reaches the wire beforecomposer installstarts, 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.logis 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" —
WebhookHandlerwas documented only in1.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, throughRequest::getInstance()rather thannew Request()— constructing one re-runscalcParams(), 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 explicitexec('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. ApplicationLateRouteTestasserts 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()andexec(), 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'shandle(), which had nothing.- Absent still means enabled.
getSetting()'s own default isfalse, so reading the key without an explicitnullwould 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
SessionOptOutTestprobe 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-
middlewarecase 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
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-cssregistration, both password fields, insidestyle=""— that theme's sign-up form.tailwindchange-password, all three fields, insideclass="".
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()sendssmtpPassIsSet, 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.
And the sweep flaked, which was the blink one layer deeper¶
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()besidefiles()andmatching(). - 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:checkexits 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_storageonfiles— yes. Nownotice.site_urlinferred from the request — no, and it staysdegraded. An unsetAPP_URLmeans 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.cachefalling 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
matchon$check['status']without adefaultnow throws, andstatus === 'ok'as a health test should becomeHealthStatus::from($s)->isHealthy(). - The scaffolded
CLAUDE.mdsays whatnoticemeans, since that is the file a monitor gets configured from.