14 August 2026¶
39 changes:
- The server now says where its own time went
- An API request is not a browser session
auth:unlock— lifting a lockout you gave yourself- The toolbar answers "who am I, and until when"
- A parent class for services, and a tab that admits they exist
- An Errors tab, for what the browser threw
- A Client tab: what the browser thinks the world is
- The page outranks the toolbar
- An API playground in the toolbar — and a doubled slash it found
- A WebSocket daemon that can read a Redis stream
- The body of a DELETE request
- A
?in a raw fragment now binds where it was written initwill not scaffold over your application- Debug data for an application that does not use the API layer
- The other way to lose a page
- The toolbar injects through the response, not an output buffer
- Two build settings that failed quietly
- Three dead stubs, and four corrections to older posts
scaffold:spa— a front end for an application that already exists- The MCP server says which project it is
- Typed endpoints, generated from the document
- Where the suite's fifteen minutes actually go
- A shared document, seven eight-second tests, and a
?in the bar - A scaffolded application was set up to learn it the hard way
- The suite was running
composer updatesixty-one times - The test database was afraid of losing data
TRUNCATEis slower than dropping the table- The comment said
// product 1 = Apple - The same change made PostgreSQL slower
- A suite that only passed in one order
- The guide described an API nobody had built
- The widget area that rendered nothing
maxRuntimewas a range, and it read like a number- An empty ban list is still a ban list
- The ingest dropped the id it had just read
- Which rule said no
- Four corrections from the other side of the boundary
- "Minor variable name changes"
- A blank page is not an error
The server now says where its own time went¶
The Time tab learned to subtract client from server and SQL from both. What it still could not show was what the server's share was made of: the API path had no timers at all, and the phase before any application code runs — connecting to the database, booting providers, starting the session — had never been on the timeline at all.
Added¶
A real bootstrap segment, around the whole of Application::init(), with
db-connect, providers and session inside it. These are the classic
invisible cost: they happen before a single line of application code, and no
amount of profiling the controller finds them.
Timing them needed a new way to record. The collector that would have measured
them is registered by one of those phases, so it does not exist while they run.
TimeCollector::addSegment($name, $start, $end) takes absolute times, so each
phase is measured as it happens and handed over at the end keeping its own place
— the existing addCompletedSegment() back-calculates from "now minus the
duration", which is right for one piece of work reported as it finishes and
stacks three of them at the same instant when they are reported together.
middleware and action on the API path. This is why a SPA's Time tab
showed a single segment: everything a SPA does happens here and none of it was
measured. middleware stops when the pipeline reaches the core and action
starts there, so work in between is charged to the action rather than the
pipeline — and middleware is stopped again after the pipeline returns, because
an OPTIONS preflight or a refused authentication never reaches the callback, and
a timer left open would read as an action that took the whole request.
The request id, copyable from the requests list. On hover, beside the path —
the value to paste into a bug report or a log search, and what
/devpanel/logs?request=<id> takes. Not a column: sixteen characters of noise
that somebody reads once in their life should not permanently own a sixth of a
narrow table.
Changed¶
boot is now debugbar. It measures the toolbar's own provider registering
its collectors — useful when the toolbar itself is suspected of costing
something, and misleading under a name that reads as application startup. That
name is now taken by something that is.
Server-Timing carries the phases, so the browser's own network panel draws
them with no toolbar involved, and the database's share travels as a duration
rather than only a count: db;dur=24.5;desc="3 queries".
Only the framework's own phases are published. An application can name a timer anything at all — including something it would rather not have in a log file — and this header is written to every access log between here and the client.
An API request is not a browser session¶
A website knows its visitor from a session cookie. An API knows its caller from
the credential presented on the call — that is what makes it an API rather than
a website that returns JSON. The framework's API middleware wrote its answer
into $_SESSION anyway, and in an application serving both from one origin the
two share that cookie.
Both directions were live, and both were found by using the debug toolbar on a real application:
- Writing. A call authenticated with one user's token set
$_SESSION['user']and['logged'], so the browser's next page belonged to that user — whoever was signed in on it. - Erasing. An anonymous call ran
unset($_SESSION['user'], ['logged'], ['uid']), to be sure a cookie could not authenticate it. That achieved the goal by destroying the session: one unauthenticated poll from a widget signed the reader out of the website. - Reading. With the writes removed,
getCurrentUser()fell through to the session — so a website login authenticated API calls that presented nothing, andlogoutcould not work at all: revoking the token left the cookie answering for it.
Fixed¶
Pramnos\Http\RequestIdentity — a request settles who is calling, possibly
nobody, and that answer stands. User::getCurrentUser() consults it first and
stops there when it is sealed, instead of falling through to the session.
RequestIdentity::seal($user, 'accessToken'); // this request is $user
RequestIdentity::seal(null); // this request is anonymous
The distinction between sealed-and-anonymous and never-asked is the whole mechanism: only the first stops the session being consulted.
ApiAuthMiddleware and UnifiedAuthMiddleware both seal instead of writing the
session. The session path of UnifiedAuthMiddleware still reads it — that path
is the session, and its X-CSRF-Token requirement is what makes it safe — but it
no longer decides anything for the rest of the request either.
The session-path asymmetry is deliberate and worth stating: an application that
needs both (an authserver whose own web UI calls its own endpoints) should use
UnifiedAuthMiddleware, which accepts a Bearer token or a session cookie plus
a CSRF token. A flag that simply let cookies authenticate an API would be the
same thing without the protection — a browser sends cookies by itself, so any
site could then make authenticated calls on the user's behalf.
Also fixed¶
$_SESSION['uid'] was never set on the token path, while
Session::staticIsLogged() requires logged and uid. So
getCurrentUser() answered false for a perfectly valid token: /me returned 401
to a signed-in user, and a SPA showed a login button to somebody who had just
logged in. It is moot now — nothing on that path writes the session — but it is
why the investigation started.
Documentation¶
The Authentication guide taught the broken pattern. Twelve places across two
guides showed if (!isset($_SESSION['user'])) as the way to check
authentication in an API controller — so an application that followed the docs
inherited the cross-wire. They now use User::getCurrentUser(), with a table
separating the two ideas and a warning explaining what reading the session costs.
Tests¶
ApiAuthSessionIsolationTest holds the line in both directions: an anonymous
call leaves the session byte-identical, and a website cookie does not identify an
API request. The old testNoTokenClearsAmbientSessionIdentity asserted the
bug — it is now testNoTokenMeansAnonymousWithoutDestroyingTheSession.
A PHPUnit extension resets the request identity before every test. It is
request-scoped by design, and a test run is the one place where that assumption
fails: thousands of "requests" share one process, so an identity sealed by one
test answered for every test after it — 135 failures in tests that had nothing to
do with authentication. Doing it centrally rather than in each setUp() matters,
because the state is reached indirectly and any list of "tests that need it"
would go quietly out of date.
auth:unlock — lifting a lockout you gave yourself¶
The progressive login lockout is doing its job when it locks somebody out: three wrong passwords cost a minute, ten cost an hour. That is right for the internet and unhelpful for the developer who has just mistyped a fixture password and cannot test the login flow they are working on.
php pramnos auth:unlock admin # this identifier, every scope
php pramnos auth:unlock 2 --scope=user # by user id
php pramnos auth:unlock 10.0.0.5 --scope=ip
php pramnos auth:unlock --list # who is locked, and for how long
php pramnos auth:unlock --all # everything (development only)
A failed login writes to more than one scope — identifier (what the form was
given), user (the account it resolved to) and ip — so clearing "the lockout"
means clearing all three unless told otherwise.
It reports what it found before clearing it: "nothing was locked" and "a lockout was lifted" are different answers, and somebody running this wants to know which one they got.
What it is not¶
It clears the counter that says how many times somebody has failed, and nothing else. A wrong password is still a wrong password afterwards.
--all refuses to run outside development, and says why: "clear every lockout on
this server" is precisely what somebody working through a password list would
want, and a command that offers it on a live installation is a hole with a
friendly name.
The toolbar answers "who am I, and until when"¶
"It worked and then it stopped" is almost always one of three things: the credential expired, the client is sending a different one than it believes, or it sent none and the server fell back to a session cookie that exists only on the developer's own machine. Each is a different afternoon, and none of them was visible.
Added¶
An Auth tab, fed by a new AuthCollector:
- who — user id, username and type, or anonymous;
- what —
apiKey,accessToken, the deprecateduserAuthheader, or a session cookie, reported in the order the middleware checks them so the answer is the credential that will actually be used; - where from —
accessToken headerversusAuthorization: Bearer. "The token" means a different header to different developers, and a client sending the one the server is not reading looks exactly like a client sending nothing; - until when — a countdown, and the tab turns red once the expiry has passed, which explains every refusal above it in the list at once.
The token never travels¶
Only identity claims do — sub, iss, aud, iat, exp, nbf, jti,
userid, username — and nothing else, because an application may put anything
in a token including data it would not want in a network log. This payload is
attached to responses, so a live credential in it would hand out the thing the
panel exists to explain.
The claims are read without verifying the signature, on purpose: this reports what the client sent, and a token the server is about to reject is exactly the one worth looking at. Whether it was accepted is the status of the request beside it.
The expiry travels as the token's own absolute timestamp rather than as "seconds remaining". The response may sit in a browser for a while before anybody opens the tab, and a countdown that started when the request was made would be reassuring and wrong.
Fixed¶
Signing in did not update the Auth tab — reported from a real SPA, where it still said "anonymous" until the page was refreshed. Auth is a state, not a property of a request: the call made before signing in is over, and what a reader wants is who they are now. It follows the most recent request unless one has been picked, and says so when the state shown is newer than the request in view.
A SPA no longer links to the DevPanel. The DevPanel is a server-rendered page
behind MVC routing — a controller, a layout, an admin session — and a SPA
project's server answers JSON, so /devpanel is a 404 there. The link was drawn
in both deliveries on the assumption that a framework route exists wherever the
framework does. The data island is the exact test: it exists only for a page the
MVC middleware rendered, which is the pipeline the DevPanel lives in.
A parent class for services, and a tab that admits they exist¶
In a Services + API + SPA project the domain logic lives in
src/Services/*Service.php — plain classes, deliberately, with nothing for the
framework to hook. The debug toolbar's Models tab was therefore empty for a
request that had done all of its work in services, and an empty tab named after
the other style says nothing at all: not "nothing happened", not "your code does
not appear here". Just nothing.
Pramnos\Application\Service¶
Services now have a base class, the way models have one. Extending it is the whole contract:
namespace App\Services;
use Pramnos\Application\Service;
class BillingService extends Service
{
public function overdue(int $days = 30): array
{
return $this->measure('overdue', fn(): array => $this->queryBuilder('invoices')
->where('due_at', '<', gmdate('Y-m-d', time() - $days * 86400))
->where('paid', 0)
->getAll());
}
}
| Member | What it does |
|---|---|
__construct(?Database $database = null) |
The connection to use, or none. |
$this->database() |
Resolved on first use, not at construction. |
$this->queryBuilder(?string $table) |
A builder on that connection. |
$this->measure(string $name, callable $work) |
Runs it, returns its value untouched, records the duration. |
Laziness is the part worth stating: a service constructed in a unit test that
only exercises pure logic never opens a database connection to do it. And
measure() re-throws whatever the callback threw after recording the attempt —
the call that failed is the one worth seeing in the toolbar, so swallowing it
would turn a debugging aid into a bug-hiding one.
The Domain tab¶
ServicesCollector is fed by that base class, so recording is automatic rather
than opt-in: constructing a service records it, and measure() adds the cost of
one operation. The tab is now Domain, with a Models section and a Services
section — the label follows the content instead of the content following the
label.
The two empty states are distinguished, because their fixes are different: no
services recorded means the class does not extend the base, while no call was
timed means it does and no method has called measure() yet. The badge counts
both sections, so a services-oriented request no longer shows 0 above a panel
listing six calls.
The payload keeps its models key, with services beside it as its own
collector — anything already reading the payload is unaffected. One renderer
still draws both deliveries, so the server-rendered toolbar and the SPA panel
gained this at the same moment.
Scaffolding¶
create:service and the SPA scaffold's StatusService now generate services
that extend the base — and the status service uses measure(), so a new project's
very first request shows a timed service call in the panel.
Not yet¶
A container-resolved timing proxy would need neither measure() nor a base
class, and stays a follow-up: it should wait until services are actually resolved
through the container.
Documentation¶
- Application Styles Guide — the
Servicebase class, its four members, and what inheriting buys. - Debugging Guide — the Domain tab, and why it is one tab rather than two.
An Errors tab, for what the browser threw¶
The toolbar's Exceptions tab is the server's. Its blind spot is everything
after the response arrives: a screen that throws while rendering the data it just
fetched, a promise nobody caught, an ApiError a screen turned into a friendly
message. All of those left the panel looking perfectly healthy next to a page
that was visibly broken.
What arrives by itself¶
window.onerror and unhandledrejection, in both deliveries — a server-rendered
page and a SPA, from the same source. Both listeners are passive: they never
call preventDefault(), so the console still shows the error and any other
handler still runs. A debug panel that swallowed errors would be worse than one
that missed them.
A cross-origin script reports a message and no Error object at all; that is
kept too, because a 404 on a bundle is a real finding.
What you hand over¶
The interesting failures are the ones somebody caught — and a caught error reaches no global handler:
import { reportError } from './lib/debug.js';
try {
await api.post('/things', body);
} catch (error) {
showMessage(error.message);
reportError(error, { kind: 'ApiError' });
throw error;
}
A scaffolded project now does this in three places without being asked:
lib/api.jsreports everyApiErrorwith the request that produced it;- and every network failure — which has no response, no status and no
_debug, so nothing else in the panel recorded it at all; App.sveltewraps each screen in a<svelte:boundary>whoseonerrorhands the failure over while the shell — header, navigation, a way out — stays on screen. Before this, a screen that threw took the whole application down.
On a server-rendered page the same entry point is window.__pramnosDebugBar.reportError(error).
The tab¶
It appears only once something has been thrown, red and with a ⚠, next to
Exceptions. Each row carries the kind, the message, a folded stack (masked — this
panel gets screenshotted into bug reports) and the request it happened after.
That last column is a heuristic and says so: the code that threw was reacting to
the call that had just come back, which is right nearly every time, and an
explicit request from the caller always wins.
Identical failures collapse into one row with a ×4. A render loop throws the
same error thousands of times, and fifty copies of it would push every other
finding off the panel.
Errors are collected before the bar exists — in a SPA the first one is often the reason no request was ever made — but a production page that throws still gets no toolbar: the bar is built only by a response that carried debug data.
Documentation¶
- Debugging Guide — the Errors tab, what arrives by itself, and the three call sites a scaffolded project already has.
A Client tab: what the browser thinks the world is¶
Three questions that used to be answered by opening the browser's own devtools and knowing where to look: what did the shell actually inject, where does the router think it is, and what is in storage. The toolbar's new Client tab answers all three, and it is the only tab present on every page — for every other tab no data means no tab, but here absence is the finding.
Runtime configuration¶
window.__PRAMNOS__ as injected, with secrets masked by key name. A page with
none says so and says what that means: on a server-rendered page there is nothing
to inject, while in a SPA it means the shell did not run and the API client is
falling back to its built-in defaults — which is a different bug entirely.
Router¶
The current URL, the router base, the resolved route and its params. The base is
printed next to the path because that pair is the deep-link failure: an
application served under /app whose router base is empty resolves every deep link
to its home screen and says nothing at all. When the path does not start with the
base, the panel says so in as many words rather than leaving two values side by
side to be compared.
The route name belongs to the application, so the router reports it — one line in
the scaffolded lib/router.js, on every navigation:
Without it the URL and the injected base are still shown; only the route name
goes missing. The generated shell now publishes routerBase as well, so the
comparison works in a project whose router.js predates this.
Storage¶
Every key in localStorage and sessionStorage, values masked by key name and
truncated when long. A masked value still reports its length, because "there is
a token and it is 900 characters long" is usually the whole finding: a stale token
survives a deploy, the server signs with a new key, and every call then fails in a
way that looks like a server problem.
An area that refuses to be read — private mode, a blocked origin — costs its own section and nothing else. One that is not there at all is reported as absent rather than as broken; those are different findings and they have different fixes.
Documentation¶
- Debugging Guide — the Client tab's three sections, and what each absence means.
The page outranks the toolbar¶
Injecting the debug toolbar can no longer cost you the response. Anything thrown while rendering gives the body back exactly as it arrived, and a decorated body shorter than the original is discarded. Both the middleware and the output-buffering path enforce it.
What happened¶
With the toolbar booted, a plain HTML response reached the browser as 200 with
Content-Length: 0. PHP discards an output buffer when its callback throws, so
a 37KB page was produced by the application and then dropped on the way out.
Every signal pointed away from the truth:
- the response headers said the request had succeeded —
Server-Timing,X-Pramnos-Debugwith a query count and a request id, all present and correct; - nothing was logged, because the injection runs at shutdown;
- the same request under the CLI SAPI rendered perfectly, because the provider's boot condition differs there — which sends you looking for an Apache or output-compression fault;
- the 200 made every uptime check pass.
An empty body on a 200 is the worst shape a failure can take: invisible to every
automatic check, and to a person it looks like a broken front-end build. One
development environment lost a day to it and turned APP_DEBUG off — which is a
real loss, because the toolbar is the thing that would have explained it.
The fix¶
The rule is now explicit in both injection paths, and it is the general one rather than a patch for the specific throw:
- Anything thrown is caught, and the un-decorated response is returned. Rendering reads collectors, the session, the container and an asset from disk — none of which have anything to do with the page that is ready to be sent.
- A shorter result is discarded. A decoration that shortened the body has failed, whatever it thinks. This is a guard against a future change rather than a known path, and it is one line.
The decision now lives in DebugBarServiceProvider::decorate() rather than inside
the ob_start() closure, because a closure that runs at shutdown cannot be
tested — and this is the code that decides whether a page is delivered at all. It
has tests now: a bar that throws while rendering returns the page byte-for-byte,
in both paths.
If the toolbar ever vanishes from a page that is otherwise fine, that is this guard working. A missing toolbar is a bug report; a missing page is a phone call.
Documentation¶
- Debugging Guide — "The page outranks the toolbar", under how the data gets there.
An API playground in the toolbar — and a doubled slash it found¶
The toolbar's new API tab lists the endpoints in the project's own OpenAPI document, calls one with the parameters you give it, and shows the answer. This is the last item on the debug-toolbar roadmap.
It is a real request¶
The call goes through the same server, the same middleware and the same authentication as the application's own, and it is recorded in the requests list like any other — so Time, SQL, Logs and Domain answer for the call you just made. A playground that stubbed the request would answer a question nobody asked.
Recording is explicit rather than left to the transport wrapper, and the unwrapped
fetch is used to send: a SPA has no wrapper at all (its API client reports its
own calls), and on a server-rendered page this is what keeps the call from being
recorded twice.
Nothing to maintain¶
The endpoint list is not a list — it is the OpenAPI document
(www/api/openapi.json, from npm run docs:build). An endpoint appears because it
is documented, and one that is missing is a documentation gap the tab has just
reported.
- Where it sends: the API prefix the shell injected. The document's own
serverslist is deliberately not preferred, and an absolute URL in it is ignored — a generated document names production URLs, and sending a development call there because a list was ordered that way is the one mistake this tab must not make. - Bodies come pre-filled from the document's
example, or from the schema's properties one level deep. A skeleton of a deeply nested schema is harder to correct than an empty object is to fill. - Credentials: the
apiKeyfrom the injected configuration, cookies (same-origin, so a signed-in browser session authenticates the call), and a stored bearer token if the page has one — found by key name, with the panel naming the key, never the value. One click refuses it, which is how "is this endpoint actually public?" gets answered.
Fixed: every documented path had a doubled slash¶
Building the playground surfaced a real bug in the generator every project copies
(scripts/apidoc-to-openapi.cjs): it prepended / to the path unconditionally, so
an endpoint documented the normal way —
— became //status in the OpenAPI document. A doubled slash is not the same path:
anything that sends it verbatim gets a 404 that reads as a routing bug in the
application. Every apidoc-derived path in every generated document was affected.
The fix adds the slash only when it is missing. The converter also stopped running
on load (require.main === module) so its parser can be driven by tests, which it
now is. Refresh the script in an existing project with
project:resync --scripts, then regenerate: npm run docs:build.
The playground normalises // on its own too — it has to work against documents
generated before the fix.
Documentation¶
- Debugging Guide — the API tab: where it sends, what it sends, and what it refuses to send.
A WebSocket daemon that can read a Redis stream¶
SSE gained replay when RedisStreamDriver landed: id: frames, Last-Event-ID,
and a driver that can hand a reconnecting client the events it missed. Using it
turned out to be blocked by one class — the WebSocket daemon could not read a
stream at all, and said nothing about it.
The silence¶
An application with both transports has two consumers of one backplane. SSE reads
through SubscribableDriverInterface, which the stream driver implements. The
WebSocket server cannot: it runs a single-threaded stream_select() loop, so it
uses a raw RESP socket — and the only one that existed issued SUBSCRIBE.
SUBSCRIBE on a key that only ever receives XADD is a perfectly healthy
subscription that is never delivered anything. No error, no warning, no events.
So the choice was:
- publish with
RedisDriver→ the daemon works, SSE loses its reconnect window; - publish with
RedisStreamDriver→ SSE replays perfectly, the daemon receives nothing.
The only way out was to publish every event twice, which puts two representations of one event on the backplane — the thing a driver abstraction exists to prevent.
RedisStreamSocket¶
The same shape as the pub/sub socket, issuing XREAD BLOCK 0 instead. That is one
command whose reply arrives when an entry does, which is exactly the property a
select loop needs.
use Pramnos\Broadcasting\RedisStreamSocket;
$server->useRedisIngest(new RedisStreamSocket($redisConfig, ['app:chat'], $lastIds));
useRedisIngest() now takes the RedisIngestInterface both implementations share,
so every existing call still type-checks and the choice of ingest follows the
choice of driver instead of being independent of it.
The driver's envelope field is passed through unchanged, so the server's fan-out
cannot tell which transport brought the event. An entry written by something else
is handed over as a JSON object of its fields rather than dropped.
The cursor is the bonus¶
A subscription has no position; a stream read does. A worker restarted mid-deploy
with SUBSCRIBE misses whatever was published while it was down. cursors()
returns the last id read per stream — persist it, hand it back as the
constructor's third argument, and the restart costs nothing. Absent a cursor,
reading starts at $: new entries only.
Cursors survive close() too, because a caller that closes in order to reconnect
wants to carry on where it was.
Also¶
RedisSubscriberSocket's docblock now says it is pub/sub only, and what
pairing it with the stream driver produces. That sentence would have saved reading
the RESP framing to find out.
Documentation¶
- Realtime Guide — "The ingest has to match the driver", with the pairing table and the cursor.
The body of a DELETE request¶
$_POST is filled by PHP for POST only. A handler reading it under DELETE finds
nothing, and nothing about the code says it will — which shipped as three
separate bugs in one application: banning worked and unbanning was impossible; an
endpoint worked over POST and failed over DELETE on the same route; a third
accepted JSON and refused the form-encoded body every other endpoint used.
All three passed their unit tests. That is the part worth keeping: a test that
seeds $_POST for a DELETE proves nothing, because it constructs a state no real
request can produce. They were found with curl.
body() and bodyValue()¶
$request = new \Pramnos\Http\Request();
$fields = $request->body(); // whatever this request carried
$id = $request->bodyValue('id');
$reason = $request->bodyValue('reason', ''); // with a default
| Method | What comes back |
|---|---|
POST |
$_POST, or the decoded JSON body |
PUT / DELETE / PATCH |
the decoded body — PHP fills nothing for these |
GET / HEAD |
$_GET, because on a GET the query is the input |
| anything else | the decoded body |
Two differences from allCurrent(), and both were needed:
- The method is read live.
allCurrent()answers from the method captured when the singleton was built — correct in production, stale anywhere the method is set afterwards, which is every test. A fix built on it passes over HTTP and fails under PHPUnit, and that happened. - The body is decoded on demand, so the accessor works even when the object was constructed under a different method.
PATCH now has a store at all (Request::$patchData), and all('PATCH')
returns it instead of falling through to an empty $_REQUEST.
Fixed: JSON bodies were decoded one level deep¶
(array) json_decode($raw) casts only the top level, so every nested value stayed
an stdClass. A handler iterating a nested list and checking is_array($row)
rejected the whole payload — one import endpoint answered
200 {"success":true,"imported":0,"invalid":1,"reason":"Entry is not an object"}:
a success status, nothing imported, and the only evidence a counter nobody reads.
It is a regression rather than a feature that never worked: the endpoint had been a
standalone script calling json_decode($raw, true) itself, and moving it onto the
framework's parsing is what broke it. All three sites (POST, PUT, and the
JSON-in-a-GET-key path) now decode associatively.
Fixed: a DELETE body is no longer run through parse_str regardless¶
parse_str('{"id":7}', $out) produces ['{"id":7}' => ''] — non-empty, so an
empty() fallback never fires, and nonsense, so nothing reads correctly either.
That garbled-but-plausible array broke every JSON caller of an endpoint inside the
hour its form-encoded case was fixed.
JSON is now detected from the content type, or from a body starting {/[ when the
header is absent (a hand-written curl and more than one HTTP client omit it). A
body that declares or looks like JSON and is not valid JSON yields an empty
array rather than one invented key.
Documentation¶
- Framework Guide — "Reading the request body", with the per-method table and the JSON rules.
Request::$putData,$deleteDataand$patchDatanow carry docblocks saying why they exist. One sentence there would have been enough for any of the three bugs.
A ? in a raw fragment now binds where it was written¶
Reported from a consuming application, and the cost was in the failure mode rather
than the failure. A query mixing where() with a whereRaw() carrying ?
placeholders returned false from first() — no exception, nothing in the log —
and the only symptom was Attempt to read property "fields" on false pointing at the
consumer, several lines from the cause. They rewrote the query as a prepared
statement rather than spend longer on it.
What was wrong¶
This builder does not use ?. It emits the framework's own typed placeholders —
%s, %i, %d, %b — which Database::prepare() substitutes positionally. A raw
fragment was emitted verbatim, so a ? in one stayed a literal ? in the
statement while its value was still appended to the binding list: one more value than
there were placeholders, and every binding after the fragment shifted by one.
$qb->from('chat_messages')
->where('created_at', '>=', $start)
->where('created_at', '<=', $end)
->whereRaw('channel_id IN (SELECT id FROM channels WHERE station_id = ?)', [$station]);
// before: … created_at >= %s AND created_at <= %s AND channel_id IN (… station_id = ?)
// three bindings, two placeholders, and a ? the server rejects
// after: … station_id = %i, bound in this clause's own position
What it does now¶
Each ? becomes the placeholder its own binding's type needs, in the position it was
written — which is what makes the ordering come out right, since the fragment already
sits in the correct place in the clause. Two things are deliberately left alone:
- A fragment with no bindings.
whereRaw('enabled = TRUE')is used across the framework itself, and a?with nothing to bind may be PostgreSQL'sjsonb ? keyoperator or a literal. Rewriting it would be a guess. - A
?inside a quoted string.label = 'why?'means what it says, escaped quotes ('it''s') included.
And it fails loudly now¶
A placeholder count that does not match the bindings throws from the whereRaw()
call itself — in the caller's own file, where the mistake is:
Separately, a statement that cannot be prepared is now written to the application
error log with its SQL. Outside strict mode the caller still gets false — that is
long-standing behaviour and not something to change under existing applications — but
the false now leaves a trail, which turns the property-read-on-false report into a
two-minute fix.
Added: orWhereRaw() and orHavingRaw()¶
orWhereRaw() simply did not exist, while orWhere(), orWhereIn() and
orWhereNull() all did. The only route was whereRaw($sql, $bindings, 'or'), which
reads as an internal detail because the third parameter is one. orHavingRaw() comes
with it, for the same reason.
havingRaw() shares the placeholder fix: its bindings are a separate bucket merged
after the WHERE's, so a ? there was wrong in the same way, in a place that is harder
to notice — an aggregate that quietly returns nothing looks like data that is not
there.
Tests¶
19 unit tests on the compiled SQL, and six integration tests that assert the statements execute and return the right rows against a real database, across the engines the suite covers. The unit tests would have passed a fix that produced valid SQL binding the wrong values; the integration tests would not.
Documentation¶
- QueryBuilder Guide —
whereRaw(), the two placeholder styles, what is left alone, and what throws.
init will not scaffold over your application¶
Named as the dangerous finding of a review, and it was: init had no --force, no
--dry-run and no already-initialised check, and writeFile() was a bare
file_put_contents with no existence test.
What it could do¶
Running pramnos init in an existing project silently overwrote app/app.php,
composer.json, CLAUDE.md, README.md, Dockerfile, docker-compose.yml,
dockertest, phpunit.xml and src/Console.php, and dropped ~18 stock MVC
controllers into src/Controllers/ — which in an attribute-routed application
become live routes, because the loader takes whatever is in that directory.
None of it is recoverable without version control, and a scaffolding tool is exactly what somebody runs optimistically in the wrong directory.
Three things were already non-destructive by design — the .gitignore append, the
package.json merge, the screens-registry edit — so the intent existed. It simply
was not applied to the rest.
What happens now¶
A directory that already contains app/app.php is refused, before the first
question as well as before the first write — an interactive run that asks fifteen
questions and then says no is its own kind of unhelpful:
This directory already holds an application.
Found: app/app.php
Running init here would overwrite files including:
app/app.php
composer.json
…
and add stock controllers to src/Controllers/, which in an
attribute-routed application become live routes.
--dry-run lists everything that would be written, and writes nothing.
--force proceeds anyway.
The exit status is non-zero, so a script notices.
--force proceeds, and says on stdout that it is proceeding. Silence there
would be worse than the original behaviour: somebody passing --force out of habit
should be told what it just allowed.
--dry-run¶
Asks the same questions, writes nothing, and prints every file it would create or overwrite — the two lists kept apart, because they are different news. It is allowed in an existing project, since a preview is exactly what is wanted there.
It also runs no external commands (composer, docker-compose, migrations, the
docs build) and prints each one it skipped: "it did not run composer" is part of what
the reader is checking.
And it does not append to .gitignore, merge package.json, copy brand images,
download assets or generate an RSA key pair. A flag that stopped the templates but
still did those would be a trap rather than a preview — a "dry" run that changes the
working tree. The recording happens in writeFile() and one shared guard, so the
report cannot drift from what a real run writes.
Documentation¶
- Console Guide — "
initwill not scaffold over an existing application", and theproject:commands to use instead.
Debug data for an application that does not use the API layer¶
Reported by a project that routes #[Route] attributes to controllers returning
Response::json(), with no src/Api at all — a style the framework supports and the
SPA scaffolding assumes. The debug payload design is right, production is off by
construction, and Server-Timing comes free. Two things made it more work than it
should have been.
Attaching the payload was private to Api¶
Api::_attachDebugPayload() and Api::_sendServerTiming() are protected, so an
application not built on that layer had to re-implement both — about thirty lines
that decode the body, refuse a top-level list, merge the key and set the header.
Every attribute-routed project would write the same file, and each one gets to
rediscover, from an empty panel, that a JSON array has nowhere to put a key.
Pramnos\Debug\ApiDebugMiddleware now ships. One line covers every routing style:
It handles both shapes a controller returns — a Response object and a bare string —
and returns the same instance when there was nothing to attach, so a later ===
in the pipeline still means what it says. The rule about which bodies can carry the
key lives in ApiDebugPayload::attachTo() and nowhere else: a top-level array, a
plain string, HTML, or a body that already has a _debug key all come back
untouched. Api uses the same method rather than its own copy.
Inert in production: ApiDebugPayload::isEnabled() asks the toolbar whether any
collector is registered, and collectors are registered only in debug mode. One array
check per request.
The provider only booted inside Application::init()¶
bootServiceProviders() was called from init(), so an application that
deliberately does not run the MVC boot — a console-safe bootstrap, for instance — got
no collectors, while looking fully configured. Listing 'debug' in app.php's
features was necessary and not sufficient, with nothing saying so. The symptom is
that everything looks right and no response ever carries a payload.
Application::bootFeatureProviders() is now public, so a partial boot can opt in:
$app = \Pramnos\Application\Application::getInstance();
$app->bootFeatureProviders(); // registers the providers the features array lists
isDebugMode() is public¶
An application could not ask "are we in development?" without re-implementing the
four-way check — environment variable, DEVELOPMENT, and two settings — which is
precisely how two definitions of "development" drift apart. It is one question with
one answer, so it is now askable.
Documentation¶
- Debugging Guide — the middleware, and what the
features array does and does not do without
init().
The other way to lose a page¶
The empty-200 report came back after the first fix, with a sharper measurement: read
straight off the socket, the response was 523 header bytes and zero body bytes.
Not a body mislabelled by a Content-Length: 0 — a body that never left. And the
reporter was right that the guards added in f5d9521d could not help: by the time
they run there is nothing left to guard.
Reproduced, and it was not the decoration¶
A probe against a real server, with the provider booted by hand exactly as the
reporter's kernel does, and a plain echo of a page into the buffer:
| What the request does after the echo | Body delivered |
|---|---|
| nothing | 281,650 bytes ✅ |
while (ob_get_level()) { ob_end_clean(); } |
0 bytes ❌ |
ob_get_clean() — one level, no matching ob_start() |
0 bytes ❌ |
its own ob_start()/ob_end_flush() pair |
✅ |
| a fatal error | ✅ |
zlib.output_compression on |
✅ |
The probe also printed the buffer depth: two levels — php.ini's
output_buffering, plus the toolbar's. That is the whole mechanism. Code that clears
"its" buffer clears ours, and the page is inside it. Nothing errors. With
APP_DEBUG=0 there is no second level, the echo goes straight to the socket, and
the same clean has nothing to destroy — which is exactly why the reporter's
APP_DEBUG=0 measurement returned the full 37,353 bytes every time.
It also explains the asymmetry they noticed: a matched route survived because
Response::send() echoes its body, and by then the clean had already happened.
What the framework does about it¶
It cannot refuse the clean — PHP has condemned the content before the handler is called. So:
- It says so. The discard is logged with the byte count and both idioms named, so the cause is in the error log instead of nowhere. An invisible outage becomes a line somebody can search for.
- It re-sends the response at shutdown, once every buffer is out of the way,
which is the only moment an
echoreaches the client directly. Deliberately narrow: only when nothing was delivered through the buffer and what was dropped is a whole HTML document. A fragment, or a JSON body being replaced, is a discard that meant what it said.
The page comes back; the toolbar does not, because it was in the buffer that went. Verified across all six shapes above: every one of them now delivers the document.
Also fixed: the provider could boot twice¶
Constructing the provider by hand — the documented way to get collectors without
Application::init() — and calling init() booted it twice: two output-buffer
levels, and the whole toolbar in the page twice. Measured: a 9KB document came
back at 281KB. The second boot is now ignored.
Two levels also doubled the chance that a stray ob_end_clean() hit one of them, so
this is part of the same finding rather than a separate tidy-up.
Documentation¶
- Debugging Guide — "If your kernel clears output buffers", with the safe way to clear only what you opened.
The toolbar injects through the response, not an output buffer¶
Two reports of the same failure — 200 with an empty body — and the second one
measured it off the socket: 523 header bytes, zero body bytes. Both fixes before this
one were guards around a design decision. This removes the decision.
What was wrong with the buffer¶
The debug provider installed a process-wide ob_start(). That caught output from any
code path, including an application that simply echoes — which is why it existed.
The price was structural: booting the toolbar added an output-buffer level, so
code that cleared "its" buffer cleared the framework's, with the response inside it.
Measured on a real server, with the provider booted and a page echoed into the buffer:
| What the request does after the echo | Body delivered |
|---|---|
| nothing | 281,650 bytes ✅ |
while (ob_get_level()) { ob_end_clean(); } |
0 bytes ❌ |
ob_get_clean() — one level, no matching ob_start() |
0 bytes ❌ |
Both idioms are ordinary in a kernel that drops stray output before responding. With
APP_DEBUG=0 there is no second level and the same code works perfectly, which is
what made the toolbar look like the cause rather than the casualty.
What it does now¶
DebugBar::injectInto() is the one place injection happens, reached from
Application::render() and from DebugBarMiddleware. So:
- an application needs no middleware pipeline to get a toolbar — every MVC
application ends its request with
echo $app->render(); - it cannot get two, because injection is idempotent per request;
- and there is no framework-owned output buffer for anyone to destroy. The failure mode is gone rather than guarded.
This is what laravel-debugbar and Symfony's WebProfiler do: inject through the response object, install no global buffer.
What it costs, stated plainly¶
A response the framework never sees gets no toolbar — a raw echo, or a kernel that
ends an unmatched request by require-ing a page file. The page is delivered exactly
as written, which is the point. The
Upgrade Guide has the three-line change that gives
such a response a toolbar again, and the property it restores: your buffer is one
you opened and can safely clear.
While migrating, a page with no toolbar and a complete body is the expected intermediate state.
Removed with it¶
The two guards the buffer needed — the try/catch around the buffer callback, and the shutdown re-send of a discarded page — are gone, along with the buffer. What stayed is the rule they were protecting: anything thrown while injecting returns the body untouched, and a result shorter than what arrived is discarded.
The provider is now what a provider should be: it registers collectors, names the request, and captures PHP diagnostics. It installs nothing that outlives it.
Documentation¶
- Upgrade Guide — "The debug toolbar no longer uses an output buffer", with the migration.
- Debugging Guide — "How the toolbar reaches the page".
Two build settings that failed quietly¶
Both reported from a project building a Svelte admin panel against the scaffolding. Neither produced an error; both produced a wrong result that looked like a working one.
publicDir is now pinned¶
Vite's default publicDir is <root>/public, and the generated vite.config.js
lives at the project root. In an application whose web root is public/ — the
legacy admin panel, every upload, the whole site — a build therefore copies all of it
into the SPA's outDir.
Nothing warns. The build succeeds, and the output directory quietly grows by the size of the site.
spa-vite.config.js.stub now sets publicDir: 'frontend/static' explicitly, with the
reason next to it. That is where a scaffolded project keeps files to be copied
verbatim; the directory need not exist. Projects whose web root is www/ were never
in danger — for them the default was merely useless — but the setting is pinned for
everyone, because a default that is harmless in one layout and destructive in another
is not a default worth relying on.
The theme generator warns instead of whispering¶
scripts/build-theme.mjs derives the daisyUI palette from the server-rendered
theme's :root custom properties, so the two halves of an application do not look
like two products. When that stylesheet is absent it falls back to the framework's
own colours — and it did report which source it used, in a console.log phrased
exactly like the success case, among the rest of a build's output.
So a project whose theme lives somewhere the scaffold does not expect built cleanly and shipped in somebody else's brand colour.
It is now a console.warn that says what happened, names the path it looked in,
distinguishes "it does not exist" from "it exists and declares no custom properties",
and gives the fix:
⚠ theme: no palette found — the SPA will use the framework's colours, not this project's.
Looked in: www/assets/css/style.css
It does not exist.
Fix: point THEME in scripts/build-theme.mjs at this project's stylesheet, or
declare --primary-color / --text-main / --text-muted in it.
Refresh both in an existing project with project:resync --scripts (the theme
script) and by copying the publicDir line into your own vite.config.js, which is
yours to edit.
Documentation¶
- Application Styles Guide — "Two build settings worth knowing about".
Three dead stubs, and four corrections to older posts¶
Housekeeping from a review of the SPA scaffolding by a project building against it. Small items, each of which cost somebody a look.
Deleted: three orphaned stubs¶
spa-index.html.stub, spa-index.php.stub and spa-app.js.stub were referenced by
no code. A changelog post noted them as orphaned before the scaffolding landed, and
they stayed orphaned afterwards — so anybody following the old instructions would
copy a file the framework no longer knows about. Deleted rather than wired up: the
files they were superseded by (spa-shell.php.stub, spa-svelte-main.js.stub,
spa-vanilla-main.js.stub) are the ones init actually writes.
Corrected: four claims that no longer matched the code¶
Each is annotated in the post it appeared in, rather than quietly edited — a changelog that rewrites itself is not a changelog.
-
The admin screen's wrapper does not exist. 2026-08-10 — the SPA admin screen described a generated
src/Api/Controllers/Admin.phpmaking the endpoints overridable. No such file has ever been written:scaffoldSpaAdmin()writes onlyfrontend/screens/Admin.svelte, and the routes instantiatePramnos\Auth\Controllers\ApiAdmindirectly. Overriding one means adding a route ahead of it. -
The 403 message was quoted wrongly in the same post. The stub says "This account does not have permission for this section."
-
--spa-stack=svelteis not a default. 2026-08-10 — SPA scaffolding presented it as one. The default applies only to an invalid value; with the flag absent,initasks. A non-interactive run must pass it explicitly — which is exactly the kind of thing a script discovers by hanging. -
create:crudedits two of its outputs rather than writing them. 2026-08-10 — create:crud for a SPA listedsrc/Api/routes.phpandfrontend/screens/registry.jsalongside the files it creates. Both are edited, and the routes edit is skipped silently when the file is missing or carries no version-group marker to insert into — so the routes have to be added by hand there.
Why annotate rather than edit¶
A dated post is a record of what changed and when. Correcting one in place makes the record disagree with itself for anybody who read it earlier; a dated correction inside it says what was wrong and when that was found out, which is the more useful artefact and the honest one.
scaffold:spa — a front end for an application that already exists¶
Named the highest-value addition in a consumer's review of the scaffolding, and the
gap it closes is real: the SPA was reachable only through a full init, which refuses
to run where an application already is, and project:resync only refreshes files a
project already has. So the documented path for "I have an application and want a
Svelte front end" was to copy fifteen stubs by hand and do the token substitution
yourself. Somebody did exactly that.
php pramnos scaffold:spa --spa-stack=svelte # at the site root
php pramnos scaffold:spa --app-style=hybrid # mounted under /app
php pramnos scaffold:spa --dry-run # report, write nothing
It cannot damage the project¶
That is the property that makes it usable at all. Every scaffolded file passes through
one funnel in Init, and this command sets skipExisting — so a file the project
already has is left byte-for-byte and reported as kept (yours). Enforced in the
funnel rather than at each call site, so a stub added later cannot forget it.
Consequences worth stating:
- running it twice does nothing the second time, which is what makes it safe to run when you are not sure whether you already did;
- your own
www/spa.php, your ownlib/api.js, your ownApp.sveltesurvive untouched — you can use it to fill in the pieces you are missing; --forceoverwrites, for when that is genuinely what you want.
It writes what init writes¶
The same stubs, the same tokens, the same method. scaffoldSpa() became public; there
is no second implementation to drift from the first, which is the failure mode a
"scaffold this one thing" command usually has.
It records the style¶
app_style and spa_stack go into app/app.php, because spa:dev, spa:build and
project:resync all read them. Without that the front end exists and every command
that should help with it reports that the project has none — a state a project that
adopted the layout by hand was already in. A project that already declares a style
keeps it, so re-running to add a missing file cannot silently turn a hybrid mounting
into a root-mounted SPA.
And project:resync says where it looked¶
Two changes for a project whose sources are not where the framework assumes:
spa_source_dirinapp/app.phpis honoured, so the front end can live inadmin-ui/without a repo-wide rename — which is what a reviewer had to do to receive one file;- when nothing is found, or when everything was skipped, it now prints which directory it looked in, whether that came from configuration or was derived, and how to change it. The message used to be one sentence for both "this project has no SPA" and "your sources are elsewhere", and the second reading is the one that sends somebody hunting in the wrong place.
Documentation¶
- Application Styles Guide — "Adding a SPA to an application that already exists".
The MCP server says which project it is¶
An MCP client lists its servers by the name each one reports. Every Pramnos project reported "Pramnos App", so a picker with two of them open could not tell them apart.
The name came from a database-stored setting, falling back to the TITLE constant and
then to that generic default — while app/app.php's name was right there, already
read by the console, with no database involved.
mcp:serve now prefers it, in this order:
app/app.php'sname- the
titlesetting - the
TITLEconstant "Pramnos App"
The ordering matters beyond tidiness. A database-stored title is reachable only
through Settings, so a project whose settings load fails for any reason fell all the
way through to the default — which is precisely how this was noticed, on a PostgreSQL
project whose settings query was failing at the time. A configuration file cannot fail
that way.
McpServer::getName() is public now as well, so what the server ended up calling
itself is a question that can be asked rather than inferred from a handshake.
Documentation¶
- Console Guide — the
.mcp.jsonentry, and why the name comes from the configuration file.
Typed endpoints, generated from the document¶
The last item of a consumer's review, and the one they expected to reduce the most bugs: screens hand-write path strings and field names while the OpenAPI document in the same repository knows both. A rename in the backend was therefore found in the browser, one screen at a time.
One function per documented operation:
import { listThings, readThing, createThing } from './lib/endpoints.js';
const page = await listThings({ page: 2, search: 'ada' });
const thing = await readThing(42);
await createThing({ label: 'new' });
Path parameters become arguments and are encodeURIComponent-ed into the URL. Query
parameters arrive as one optional object, and blank values are omitted — ?status=
and "no status filter" are different requests. A 204 is typed as returning null,
which is what the client returns for one.
What it deliberately is not¶
It is not a replacement for lib/api.js. That file holds the apiKey header, the
bearer token, the session cookie, the ApiError, the two-factor flow and the debug-panel
recording — none of which a document describes. The generated functions delegate the
call, so there is one transport and one place to change it.
It is not TypeScript. A scaffolded project is plain JavaScript: Vite, Vitest,
type: module. Emitting TypeScript would buy the same editor checking at the cost of a
compiler in every project, so the types are declarations (.d.ts) that editors read and
the runtime ignores.
It is not maintained by hand. Both files are regenerated, and say so at the top.
Staying in step with the backend means being rewritten from the document — the opposite
of scaffold:spa, which never overwrites, for the opposite reason: that command adds
your files, this one owns its own.
It does not guess. Objects, arrays, primitives, enums and $refs into
components.schemas are expanded; oneOf, allOf and a schema with no type become
any. A generated type that is confidently wrong is worse than one that admits it does
not know, because the first is trusted.
Two things found by running it for real¶
Generating against a fixture proves less than generating against a document somebody's API actually produced. Two defects came out of doing the second:
- A POST with no documented request body emitted
api.post(path, body)while the signature took nothing. That is valid JavaScript, sonode --checkpassed — and aReferenceErrorthe first time anybody called it. Every POST in the fixture happened to have a body. - An
operationIdthat was already camelCase came out lowercased:listThingsbecamelistthings, a name the API's author did not choose.
Both are covered by tests now, and the generated module is parsed by node in one of them — because nothing else notices a syntax error before a build does.
Documentation¶
- Application Styles Guide — "Typed endpoints from the OpenAPI document".
Where the suite's fifteen minutes actually go¶
Measured rather than reasoned about, and the first thing the measurement did was contradict the standing hypothesis. Full analysis: Test suite performance.
The hypothesis was wrong¶
The Roadmap had assumed database setup dominates, because the suite exercises MySQL, PostgreSQL and TimescaleDB. Two measurements say otherwise:
tests/bootstrap.phptouches no database at all — constants and stubs, no DROP/CREATE, no migration run, no dump import. There is no fixed setup cost to remove.- Half the time is in
tests/Unit, which mostly has no database: 439 s of 891 s, at 60 ms per test. Integration is expensive per test (303 ms) but is 43% of the total.
Coverage instrumentation costs 12% — 17:02 with it, 14:58 without. Real, and not the lever either.
The distribution is the finding¶
| Threshold | Tests | Share of count | Share of time |
|---|---|---|---|
| ≥ 1000 ms | 203 | 2.2% | 45.6% |
| ≥ 500 ms | 516 | 5.5% | 72.1% |
| ≥ 100 ms | 1353 | 14.4% | 95.8% |
203 tests account for 46% of the run. The remaining 7646 cost eleven seconds more than those 203 do, together. There is no need to make the suite generally faster — only about two hundred specific tests.
The one that is almost funny¶
The slowest individual tests in the whole suite each take 8.00 s, to the hundredth:
8.00s BaseTestCaseTest::test_it_builds_correct_dsn
8.00s BaseTestCaseTest::test_it_builds_postgres_dsn
8.00s TestEnvironmentTest::test_full_setup_flow
… seven of them
A round 8.00 s is not work, it is a timeout. Those tests construct a PDO against
hostnames like testhost — deliberately unresolvable, because what is being asserted is
which DSN was built, proven by the failure message naming the host. Then the suite waits
for TCP to give up.
One line in four places (PDO::ATTR_TIMEOUT => 1) removes 49 s without changing a
single assertion.
The plan, with numbers¶
| Change | Saving |
|---|---|
| Connect timeouts on the seven 8-second tests | ≈49 s |
InitCommandUnitTest: scaffold once per class, not per test (61 × 1877 ms) |
80–130 s |
| Integration: schema per class, data per test in a rolled-back transaction | up to 150 s |
MediaObjectTest fixtures and TwoFactorAuthService* hash cost |
40–80 s |
Around five to six minutes, without removing a test, a database or the coverage report.
And what not to do¶
Not dropping a database from the matrix: the query-builder bugs this framework has
actually shipped were dialect-specific — a ? placeholder only MySQL tolerated, a
backtick only MySQL accepts. The repetition is the test.
Not making coverage opt-in: it is 12%, --no-coverage already exists, and a coverage
report that has to be asked for is one nobody has.
Not parallelism first. paratest would give perhaps 3–4× here, but the database is
shared and each worker needs its own schema — a larger change than the four above, which
are worth five minutes between them. It is the right second step, and it gets cheaper
once schema creation has moved into one place.
Documentation¶
- Test suite performance — the measurement, the plan, and how to run it again.
- Testing Guide — "Writing a test that does not slow the suite down": the three habits that put a test in the expensive 2%.
A shared document, seven eight-second tests, and a ? in the bar¶
Three things that had been noticed and worked around rather than fixed.
The document was shared between every test¶
Three failures in one working session had the same cause, and each time the fix looked like a bug in the test that failed: an assertion about the debug toolbar passed on its own and failed in a full run.
Document is a per-type singleton, and it is mutable — framework code and tests both
write to ->type and ->themeObject. Its instances lived in a static local inside
getInstance(), so one test's document answered for every test after it. A test that set
->type = 'json' on what it thought was its own document was writing to the shared HTML
one, and the toolbar then declined to inject into a page that was HTML all along.
The instance cache is now a property, Document::reset() clears it, and a PHPUnit
extension (DocumentIsolation) calls that before every test — the same shape as
RequestIdentityIsolation, and for the same reason: the state is reached indirectly, so
any list of "tests that need to reset it" goes out of date silently.
reset() is not test-only code. A worker serving more than one request in a single PHP
lifetime has exactly this problem, and a document carries a theme, a type and accumulated
output.
Seven tests waited eight seconds each — and the obvious fix did nothing¶
The slowest tests in the whole suite each took 8.00 s to the hundredth. A round 8.00 is
not work, it is a timeout: those tests point a PDO at a hostname that is supposed not to
resolve, because what they assert is which DSN was built.
A connect timeout looked like the answer and changed nothing. Measured directly:
The block is in getaddrinfo() — DNS, before a socket exists — so no socket option can
reach it. Worth writing down, because the wrong fix was very plausible.
What worked also made the tests better:
- the three tests that asserted which DSN was built now assert on the DSN.
buildDsn()andresolvedHost()were extracted for them, so a string built from configuration is checked as a string rather than inferred from a connection error; - the tests that assert a failure point at
127.0.0.1:9— an IP literal skips the resolver, and the discard port refuses immediately.
| Before | After | |
|---|---|---|
BaseTestCaseTest |
32.0 s | 0.28 s |
TestEnvironmentTest |
28.3 s | 4.4 s |
56 s off the suite, against an estimate of 49. The connect timeouts stayed in as well, documented for what they actually cover: a host that accepts a connection and then hangs.
The toolbar can now explain itself¶
Everything written about the toolbar so far documents how it works. There was no page that answered "the request came back wrong — where do I look", and no way to find one from the place where somebody is standing when they need it.
Using the debug toolbar is organised by symptom: the request came back wrong, it is slow, it worked and then stopped, the deep link 404s, something broke in the browser, I want to try an endpoint. Each answers with the tab and the number to read.
The bar carries a ? that opens it in a new tab — losing the page you are debugging in
order to read about the tool would be its own joke. It points at the published site rather
than anything local, because the toolbar ships inside vendor/ where a relative path means
nothing.
Documentation¶
- Using the debug toolbar — the new page.
- Test suite performance — the measurement, now with the DNS correction and the achieved numbers.
A scaffolded application was set up to learn it the hard way¶
The two PHPUnit extensions that stop process-wide state leaking between tests were added
to the framework after they had cost it 135 failures once and three on another occasion.
This is about the obvious question nobody asked at the time: does a project scaffolded by
pramnos init get them?
It did not.
The audit¶
The framework had just fixed four things. Every one of them was worth checking against
what init generates, because a scaffolded application inherits the framework's
singletons, its Request, its query builder and its test setup.
| Checked | Result |
|---|---|
Isolation extensions in the generated phpunit.xml |
❌ absent |
| Generated API controllers reading a request body | ✅ staticGet(…, 'post') / (…, 'put') — the right store per method |
whereRaw with a ? placeholder in generated code |
✅ none |
| Hostnames that cannot resolve in generated test stubs | ✅ none |
--no-coverage in the generated dockertest |
✅ present, with the same reasoning |
Generated www/index.php under the new toolbar architecture |
✅ echo $app->render(), so it gets a toolbar |
One finding, and it was the one that matters, because of how it fails.
Why an absent <extensions> block is worse than it looks¶
A leak of this kind never fails where it is caused. A middleware test seals an identity, the process keeps it, and a controller test three hundred tests later finds itself signed in as somebody it never authenticated. The failure names the controller test. The obvious fix is to make that test explicit about its identity — which works, and leaves the trap in place for the next test to walk into.
So a generated project was set up to spend somebody's afternoon rediscovering exactly what the framework had already paid for, with a fix that would look correct.
And the classes did not even ship¶
Fixing the generator surfaced a second problem. .gitattributes has:
Both extensions lived in tests/Support/, and composer.json maps that namespace under
autoload-dev. So Pramnos\Tests\Support\RequestIdentityIsolation does not exist inside
the composer package — a generated phpunit.xml naming it would have failed to boot in
every consumer project, while passing every test in this repository.
Both classes now live in src/Pramnos/Framework/Testing/, next to BaseTestCase and
TestEnvironment, which is where testing support that has to travel belongs:
<extensions>
<bootstrap class="Pramnos\Framework\Testing\RequestIdentityIsolation"/>
<bootstrap class="Pramnos\Framework\Testing\DocumentIsolation"/>
</extensions>
Each is now a single class implementing both Extension and
PreparationStartedSubscriber and registering $this, rather than an extension wrapping
an anonymous subscriber — which is what makes the behaviour testable at all.
Tested, including the part that has no symptom¶
A reset method nobody calls is a silent failure: the suite stays green and the leak comes back. So the tests assert the reset and the subscription.
Proving the subscription from inside a running suite takes a small trick. PHPUnit seals its
event facade once a run has started, so registering anything after that throws
EventFacadeIsSealedException — and that exception is the proof that bootstrap() reached
registerSubscriber() instead of quietly doing nothing.
The generator test reads the class names out of the generated XML and asserts each one
loads and lives under src/. That is the assertion that would have caught the
export-ignore hazard on its own.
Fixed¶
pramnos initwrites an<extensions>block registering both isolation extensions, with a comment saying what they are for so that tidying up does not remove them.RequestIdentityIsolationandDocumentIsolationmoved toPramnos\Framework\Testing, so they exist in a consumer'svendor/.- The Testing Guide claimed a connect timeout fixes an 8-second DNS wait. It does not — measured — and the row now says what actually works.
Documentation¶
- Testing Guide → Isolating process-wide state
— what leaks, and why an extension rather than a
setUp(). - Upgrade Guide → Test isolation extensions, for existing projects — the two lines to add, and what to expect when a test starts failing or starts passing.
The suite was running composer update sixty-one times¶
InitCommandUnitTest was the most expensive class in the suite: 114.5 s for 61 tests,
1877 ms each. The performance study said why —
each test scaffolds a whole project, several hundred file writes — and proposed scaffolding
once per class.
That diagnosis was wrong, and the way it was wrong is the useful part of this entry.
Two measurements¶
The proposal died first, for a boring reason: those 61 tests use 42 distinct option-sets, because what most of them assert is what a different set of answers produces. There is no shared tree to share.
Then the actual measurement:
| Time | |
|---|---|
init into an empty directory |
1.9 s |
cp -a of the finished tree (2556 entries) |
0.078 s |
Producing the files is 25× cheaper than the run that produces them. The scaffold was never the cost. A profile, which took two minutes, said where the cost was:
85% php::usleep ← runProcessWithSpinner, polling two child processes
12% php::file_get_contents (15 calls) ← downloading library assets, over the network
< 3% everything that writes a file
init ends by running composer update and composer dump-autoload as real
subprocesses, and downloads front-end assets over HTTP. Every one of the 61 tests did
both, and exactly one of them passed --no-download.
So the class was not merely slow. A unit-test suite depended on the network and on
composer resolving a throwaway composer.json — which also explains why its timings
wandered from run to run.
The fix is a flag that was missing anyway¶
Scaffolding files and installing dependencies are separate jobs, and wanting only the first
is a normal thing to want: CI that installs from its own lockfile, a machine with no
network, a project whose vendor/ is committed.
Skipped installing dependencies (--no-install).
Run composer install before serving the application.
Reported twice — where the step would have happened, and again in the closing next-steps list — because the alternative is a fatal about a missing autoloader with nothing to connect it to. Under Docker it also skips the framework migrations that follow, since those run the new application's own CLI and need the autoloader that was not generated.
What it bought¶
| Class | Before | After |
|---|---|---|
InitCommandUnitTest |
114.5 s | 0.56 s |
InitCommandTest |
19.0 s | 0.42 s |
InitOverwriteGuardTest |
5.3 s | 0.02 s |
136 s net of a 15-minute suite, after subtracting the 2.1 s of the new
InitNoInstallTest — which installs once, on purpose, so the default path stays covered.
Two other tests keep the real steps because the steps are their subject: one asserts the
flag's own reporting, and one asserts that a dry run still names the commands it would
have run.
InitSpaScaffoldingTest did not move at all, and that is the check on the diagnosis: it
already passed --no-download and never reached the composer branch, so there was nothing
there to save.
The lesson, twice on one page¶
Item 1 of that study was also fixed wrongly on the first attempt — a connect timeout for what turned out to be a DNS block. Both mistakes have the same shape: reasoning from what the code looks like it spends time on. The profiler disagreed with the reasoning both times, in under two minutes.
Added¶
pramnos init --no-install— scaffold every file, skipcomposer update/dump-autoload, and say so. See the Console Guide.
Fixed¶
- The scaffolding tests no longer reach the network or run composer, except where that is the subject of the test.
- The performance study's item 2 now records the correct diagnosis, the measurements that killed the original one, and the achieved numbers.
- The Testing Guide's list of habits that make a test slow gained the one this was: letting the code under test shell out or reach the network.
The test database was afraid of losing data¶
126 seconds off the suite, from a configuration file, without touching a single test. The clue had been sitting in the performance study since it was written.
The clue¶
The study's table of expensive classes, read again with one question in mind — why are the MySQL classes slower than the PostgreSQL ones running the same assertions?
| Per test | ||
|---|---|---|
QueryBuilderMySQLTest |
401 ms | 92 tests |
QueryBuilderPostgreSQLTest |
67 ms | 83 tests |
FrameworkMigrationsMySQLTest |
1398 ms | 50 tests |
FrameworkMigrationsPostgreSQLTest |
269 ms | 59 tests |
Both classes in each pair do the same thing: setUp() drops and creates tables, the test
writes rows, tearDown() drops them. A 5–6× difference is not "MySQL is slower at SQL", so
the engines got measured directly:
| MySQL | PostgreSQL | |
|---|---|---|
| Connect | 2.3 ms | 5.1 ms |
2 × DROP + 2 × CREATE TABLE |
279.6 ms | 36.0 ms |
5 × INSERT |
77.0 ms | 22.0 ms |
Transaction + ROLLBACK |
7.5 ms | 0.6 ms |
15 ms for a single-row INSERT is not query execution. It is two fsync calls per
commit: the InnoDB redo log and the binary log.
The container was configured for a production it will never be¶
Full crash durability — for a database whose entire purpose is to be dropped.
dockertest --resetdb drops it on request, every integration test creates its own tables,
and nothing in it outlives a run. Nothing replicates from it, and nothing will ever be
recovered to a point in time, so the binary log was a second fsync per commit for no
reader at all.
docker/mysql/my.cnf now says so, with the measurements in a comment and a line telling
whoever finds it never to copy the file somewhere that holds data they would miss.
| Before | After | |
|---|---|---|
5 × INSERT |
77.0 ms | 2.1 ms |
2 × DROP + 2 × CREATE TABLE |
279.6 ms | 112.8 ms |
Transaction + ROLLBACK |
7.5 ms | 1.1 ms |
In the suite¶
| Class | Before | After |
|---|---|---|
FrameworkMigrationsMySQLTest |
69.9 s | 21.8 s |
QueryBuilderMySQLTest |
36.8 s | 16.7 s |
TwoFactorAuthServiceMySQLTest |
31.7 s | 21.2 s |
TokenActionMySQLTest |
20.4 s | 6.6 s |
SchemaBuilderMySQLTest |
13.9 s | 2.4 s |
| (nine classes over five seconds, total) | 223.2 s | 97.1 s |
126 s, and all 685 MySQL tests still pass.
Rebuild, or you keep the old timings¶
A container built before this config still runs and still passes — it is just two minutes
slower, in a way nothing would ever tell you. So dockertest reads
innodb_flush_log_at_trx_commit on every run and prints that command when the container is
still durable. An optimisation that depends on a rebuild needs to say when the rebuild has
not happened.
What this replaced¶
The study's item 3 proposed a base class — schema per class, data per test in a rolled-back
transaction — for an estimated 150 s. That is still the right idea and it is now worth
40–60 s, because most of what it would have saved was never DDL cost: it was fsync.
It is the third time on that page a written plan lost to a measurement, and worth saying plainly: profile the class before writing the plan. The other two took a profiler and two minutes. This one needed nothing but reading a table that was already there.
Fixed¶
docker/mysql/my.cnf— durability settings appropriate to a disposable database, with the measurements and the warning next to them.dockertestreports a container still running the old settings, and names the command to fix it.- The performance study records what the container change bought, and re-scopes the base class to what is left.
TRUNCATE is slower than dropping the table¶
78 seconds off the suite, from two classes. One of them was slow for the reason the performance study guessed. The other was not, and finding out involved a measurement that contradicts what everybody assumes about emptying a table.
MediaObjectTest — 46.8 s, and the images were innocent¶
The study guessed "one fixture image being regenerated 86 times". The images are 10×10
JPEGs and cost nothing. Reading setUp() was enough:
foreach (['usertokens', 'userstogroups', 'userdetails', 'users', 'usergroups'] as $t) {
$this->db->query("DROP TABLE IF EXISTS `{$t}`");
}
User::setupDb();
// ... then DROP + CREATE for media and mediause
Seven drops and seven creates per test. Not one of the 86 tests asserts anything about
the schema — they assert what MediaObject does with rows and files.
So the schema moved to setUpBeforeClass() and setUp() empties the tables instead. Which
raised a question worth measuring rather than assuming.
The measurement¶
Two tables, on this project's MySQL container:
| Per cycle | |
|---|---|
DROP + CREATE |
128.6 ms |
TRUNCATE |
159.5 ms |
DELETE + ALTER … AUTO_INCREMENT = 1 |
18.7 ms |
DELETE |
0.22 ms |
TRUNCATE is slower than dropping and recreating the table. It looks like the fast path
— one statement, no row-by-row work — and it is an implicit DDL statement that drops and
recreates the table internally, plus a metadata lock.
And the auto-increment reset is 18 ms of the 18.7. This class does not need it: every id
assertion in it is assertGreaterThan(0, $id) or a comparison against another id, never a
literal 1. So the per-test reset is a plain DELETE.
46.8 s → 7.2 s, same 86 tests, same 223 assertions.
The drop-and-recreate with FOREIGN_KEY_CHECKS = 0 was kept, in setUpBeforeClass(),
because the reason it was written still holds: another class may have dropped users before
this one runs, and InnoDB then refuses to create a table whose foreign key points at a table
that is not there. Classes run sequentially, so once per class is as safe as 86 times.
TwoFactorAuthService — this half the study got right¶
It was bcrypt. On PHP 8.5, PASSWORD_DEFAULT is bcrypt at cost 12 — 142.9 ms per hash —
and enabling 2FA hashes ten backup codes, so a single call cost 1.43 s. That is exactly
the runtime of testCompleteSetupInsertsNewRowOnSuccess, to the hundredth.
The framework had three password_hash($plain, PASSWORD_DEFAULT) call sites — User, the
database auth driver and the 2FA backup codes. They now go through
Pramnos\Auth\PasswordHash::make(), which behaves exactly as the bare call it replaced
unless PRAMNOS_BCRYPT_COST says otherwise. tests/bootstrap.php sets 4.
| Class | Before | After |
|---|---|---|
TwoFactorAuthServiceMySQLTest |
21.2 s | 4.2 s |
TwoFactorAuthServicePostgreSQLTest |
21.3 s | 4.2 s |
TwoFactorAuthServiceTest |
4.3 s | 0.03 s |
The algorithm under test does not change. Cost is a bcrypt parameter, so a hash made at
cost 4 is verified by the same password_verify() that ships — PasswordHashTest asserts
that directly, and asserts the salt is still per-hash, because a "make hashing cheaper"
change that quietly went deterministic would pass every other test in the class.
The knob is built to resist being turned¶
143 ms is not an accident to be optimised away; it is what makes an offline attack on a
stolen hash expensive. So every invalid value falls back to PHP's default, never to
something cheap: below 4, above 31, not a number, empty. And it does not raise — a typo in a
deployment's environment must not be able to stop people logging in, and a hash at the
default cost is never the unsafe outcome.
A production deployment should leave PRAMNOS_BCRYPT_COST unset. The
Security Guide
says so where somebody configuring a deployment will read it.
Where the suite stands¶
396 s delivered across the four items: 56 + 136 + 126 + 78. The full run with coverage has gone 17:02 → 8:27 — 515 s, more than the 396 s measured without coverage, because instrumentation is a multiplier on work done and removing work removes its share too.
Item 4 makes it four for four on the same lesson: the written plan named the fixture, the measurement named the schema. The performance study records what each guess got wrong, because that turns out to be the more useful half of the page.
Added¶
Pramnos\Auth\PasswordHash— the one place the framework turns a secret into a hash, withPRAMNOS_BCRYPT_COSTfor the one environment that should lower it.
Fixed¶
MediaObjectTestbuilds its schema once per class and empties tables withDELETE.- The Testing Guide records the
TRUNCATEmeasurement and the hashing cost among the habits that make a test slow.
The comment said // product 1 = Apple¶
Pramnos\Framework\Testing\DatabaseTestCase gives an integration test a schema that belongs
to the class and rows that belong to the test. The first class converted went from 16.8 s
to 0.56 s. It also broke six tests, in a way that turned out to be a bug they had been
hiding.
The measurement that reshaped the plan¶
With the four items of the performance study done, the distribution had changed completely:
| Original | Now | |
|---|---|---|
| Tests ≥ 1000 ms | 203 — 46% of the run | 19 — 8.5% |
| Largest single class | 114.5 s | 17.7 s (5.5% of the total) |
The concentration is gone. What is left is spread: 890 tests at ≥ 100 ms account for 82% of
the time, and tests/Integration sits at 150 ms per test across 1150 tests. That number is
the shape of the remaining problem — a per-test floor, not a few expensive classes.
The floor is DDL. Written the obvious way, an integration test drops and creates its tables
in setUp() and drops them again in tearDown(). On this project's MySQL container that is
about 113 ms for a drop-and-create pair, and QueryBuilderMySQLTest did three of them per
test — 170 ms of the 183 ms each test took, in a class that never asserts anything about a
schema.
The base class¶
class WidgetsMySQLTest extends DatabaseTestCase
{
protected static function connectionConfig(): array { /* engine, host, credentials */ }
protected static function ownedTables(): array { return ['widget_parts', 'widgets']; }
protected static function schemaStatements(): array { return ['CREATE TABLE ...']; }
}
| When | What happens |
|---|---|
setUpBeforeClass() |
Drops the owned tables, then runs the DDL |
setUp() |
Connects, and DELETEs every owned table |
tearDown() |
Closes the connection |
tearDownAfterClass() |
Drops the owned tables |
DELETE, not TRUNCATE — measured last time,
TRUNCATE is an implicit DDL statement and slower than recreating the table. Foreign keys
between owned tables are handled, and the engine differences (backticks against double
quotes, SET FOREIGN_KEY_CHECKS against nothing, ALTER TABLE against ALTER SEQUENCE) are
in one place instead of fifty.
| Class | Before | After |
|---|---|---|
QueryBuilderMySQLTest |
16.8 s | 0.56 s |
QueryBuilderPostgreSQLTest |
5.1 s | 1.47 s |
QueryBuilderTimescaleDBTest |
5.1 s | 1.49 s |
And then six tests failed¶
Converting the first class broke testInnerJoin, testLeftJoin, testJoinRaw,
testWhereExists, testWhereNotExists and testSelectSubCorrelatedSubquery — all of them
returning zero rows where they expected five.
The fixture:
private function seedTags(): void
{
// product 1 = Apple, product 3 = Carrot
$this->db->query("INSERT INTO `qb_tags` (product_id, tag) VALUES
(1, 'popular'), (1, 'sweet'),
(3, 'healthy'), (3, 'organic'),
(5, 'rare')
");
}
Those ids worked only because the products table was recreated for every test and auto-increment restarted at 1. With the schema built once per class, the counter keeps climbing and the tags point at products that do not exist.
The comment says what the code meant. So the fixture now looks the ids up by name, which
is both the fix and what // product 1 = Apple was there to explain. The identical fixture in
the PostgreSQL class had the same latent dependency, and broke in the same five tests.
This is the trap the base class introduces, and it is documented as such in the
Testing Guide: counters do
not restart, so a hardcoded foreign key in a fixture becomes a join that silently matches
nothing. resetAutoIncrement() exists for classes that genuinely assert on the sequence, and
is off by default — it costs about 9 ms per table against 0.11 ms for the DELETE.
Testing the thing other tests trust¶
A base class like this fails quietly: a schema that is not created makes some other class
fail, and a table that is not emptied makes some other class pass for the wrong reason. So
the lifecycle is asserted directly, on both engines — including two tests that call
setUpBeforeClass() and tearDownAfterClass() from inside a test body, because PHPUnit
collects coverage per test and code that only runs in those hooks is executed but never
attributed.
Two classes that could not use it, and an 85-millisecond surprise¶
TokenTest and UsersControllerTest reach the database through Factory::getDatabase(),
because that is what the code under test does — so they got the pattern by hand rather than
the base class.
That took TokenTest from 15.8 s to 6.0 s, and the remaining 180 ms per test led somewhere
worth writing down:
| Per call | |
|---|---|
Settings::clearSettings() + loadSettings() |
0.01 ms |
Application::getInstance() |
0.00 ms |
| Drop the database singleton, reconnect | 0.45 ms |
$db->cacheflush() |
84.77 ms |
cacheflush() is a file-cache directory scan, not a flag. TokenTest called it once per
test; UsersControllerTest called it three times per test — 255 ms each — in classes
where no query() call opts into the SQL cache at all, since $cache defaults to false.
The call defends against what an earlier class left behind, which one call per class
handles.
| Class | Before | After |
|---|---|---|
TokenTest |
15.8 s | 3.19 s |
UsersControllerTest |
10.65 s | 0.50 s |
Only four test files call cacheflush(), so this is not a sweeping win — but it is worth
knowing what it costs before putting it in a setUp().
Where the suite stands¶
442 s delivered — 56 + 136 + 126 + 78 + 46. Seven more classes over five seconds are candidates for the same treatment; the two migration classes are not, because for them the DDL is the subject.
Added¶
Pramnos\Framework\Testing\DatabaseTestCase— schema per class, rows per test, engine differences in one place.QueryBuilder{MySQL,PostgreSQL,TimescaleDB}Testconverted onto it.TokenTestandUsersControllerTestgiven the same treatment by hand, since they use the Factory's connection, and stripped of acacheflush()that cost 85 ms per call.
Fixed¶
- The tag fixtures in the query-builder integration tests no longer depend on auto-increment restarting at 1.
The same change made PostgreSQL slower¶
Five more test classes moved to schema-per-class, worth another 35 seconds. A sixth was converted, measured, and reverted — the identical change that made its MySQL sibling six times faster made the PostgreSQL one slower.
The five that worked¶
| Class | Before | After |
|---|---|---|
OrmRelationsMySQLTest |
10.20 s | 0.91 s |
ModelTest |
9.05 s | 2.27 s |
MessagingModelsMySQLTest |
8.38 s | 1.38 s |
TokenActionMySQLTest |
8.06 s | 1.02 s |
TwoFactorAuthTest |
5.57 s | 0.90 s |
What they were doing per test, in classes that assert what a model or a controller does:
MessagingModelsMySQLTestandTokenActionMySQLTestran the framework migrations — every test, from scratch;TwoFactorAuthTestcalledUser::setupDb()plus three migrations;OrmRelationsMySQLTestdropped and created six tables;ModelTestdropped and created four, then dropped them again intearDown().
All five now build their schema in setUpBeforeClass() and empty rows with DELETE.
The one that was reverted¶
MessagingModelsPostgreSQLTest is the same tests as its MySQL sibling against the other
engine. The same conversion, applied the same way:
| Before | After the conversion | |
|---|---|---|
MessagingModelsMySQLTest |
8.38 s | 1.38 s |
MessagingModelsPostgreSQLTest |
5.71 s | 7.34 s |
Measured twice with the change stashed and unstashed, reproducible, and reverted.
The cause was not chased further, and this entry would be dishonest if it implied otherwise. What is worth carrying forward is the rule it implies, which the numbers from the container work already predicted:
| Two tables, per drop-and-create | |
|---|---|
| MySQL | 279.6 ms |
| PostgreSQL | 36.0 ms |
The conversion pays where DDL is expensive, and PostgreSQL DDL is not. Avoiding 36 ms of DDL with per-class machinery is a trade that can lose, and here it did.
RbacFunctionsCharacterizationTest (5.95 s, also PostgreSQL) was left alone on that evidence
rather than converted and measured. That is a judgement call rather than a measurement, and
it is marked as one in the
performance study.
Not touched, on purpose¶
FrameworkMigrations{MySQL,PostgreSQL}Test — 17.7 s and 14.9 s, and the two most expensive
classes left in the suite. For them the DDL is the subject. A test that asserts a
migration builds the right schema has to build the schema.
Where the suite stands¶
477 s delivered across the study: 56 + 136 + 126 + 78 + 81. No test, database, or assertion was removed to get any of it.
| Run | Start | Now |
|---|---|---|
./dockertest (coverage on) |
17:02 | 6:58 |
./dockertest --no-coverage |
14:58 | 4:01 |
| Measured test time | 891 s | 228 s |
The distribution inverted along the way: the >= 1000 ms bucket went from 203 tests and 46%
of the run to 19 tests and 12%, and tests/Unit from 60 ms per test to 9 ms. Wall clock is now
4:01 against 228 s of test time, so about 13 s of everything else — there is no fixed overhead
left to remove. The study closes with a measured answer to the parallelism question
(bin-packing 547 classes across 10 cores: 8× at 8 workers, ceiling 13×) and a recommendation
not to do it yet, because the prize is now a quarter of what it was when the question was
first asked.
Fixed¶
OrmRelationsMySQLTest,ModelTest,MessagingModelsMySQLTest,TokenActionMySQLTestandTwoFactorAuthTestbuild their schema once per class.MessagingModelsPostgreSQLTestkeeps its per-test schema, because measurement said to.
A suite that only passed in one order¶
tests/Characterization was the last directory nobody had looked at — 17% of the run for
8.5% of the tests. Measuring it turned up four seconds of easy saving and one thing worth
considerably more: running that suite on its own failed four tests, while the full run
passed.
The measurement first¶
36.2 s across 55 classes. The remaining 45 classes, once the top ten are set aside, are 8.1 s for 681 tests — 12 ms each, already fine and not worth touching.
| Class | Before | After |
|---|---|---|
UserTokenManagementCharacterizationTest |
5.14 s | 1.31 s |
It dropped five tables and ran User::setupDb() on every test, while its tearDown() was
already cleaning up by row. The schema moved to setUpBeforeClass() and nothing else changed.
UserAdminCreationMySQLCharacterizationTest was deliberately left alone, despite being
the worst per-test class in the directory at 802 ms. It asserts on generated key values —
that the first admin lands on userid = 1, and that a scaffolded admin gets userid = 2
because 1 is reserved for the anonymous identity. The schema and its AUTO_INCREMENT
behaviour are the subject, which is precisely the documented case for not doing this.
The part that matters¶
Four failures in ApikeyCharacterizationTest. The full suite: green. Pre-existing — confirmed
by checking out the pre-work version of tests/ and reproducing it there.
The cause is a trap another class in this repository already documents in its own comments, met from the other side:
applications is a shared table name. Several classes create their own version of it,
with different columns — one has added, another has created; one has description and
organization, another does not. IF NOT EXISTS keeps whichever schema arrived first, and
this class then inserts into a table missing the columns it needs.
In a full run, something else had already left the table in a shape these tests could live with. Alone, nothing had. Fixed by dropping before creating, so the class always gets its own schema.
A suite that only passes in one order is a suite nobody can bisect. That is worth more than the four seconds this item saved: the whole point of being able to run one testsuite is narrowing down a failure, and it does not work if narrowing changes the answer.
It is the same shape as the two singleton leaks fixed earlier in this work — state left behind by whatever ran first, and a failure that names the wrong test. This one just used a table instead of a static.
So the same question was asked of every suite¶
If one suite could not run alone, the others were worth checking. Integration Tests failed
too — all seven tests of QueueControllerMySQLTest, with
RuntimeException: No such file or directory from a MySQL connect that had been given no host
at all.
That error is worth recognising: a filesystem message from a database call means mysqli fell
back to a socket path, because it was handed nothing. Here, parent::setUp() boots the
application, which builds the Factory's database singleton before the class loads the
fixture settings — so the cached handle points at nothing. The class passed for as long as
some earlier class had already built a correct singleton.
Settings::loadSettings($settingsFile);
$singleton = &Factory::getDatabase(); // discard what parent::setUp() built
$singleton = null;
$this->db = Factory::getDatabase();
Every other class that boots this way already did exactly that; this one had never needed to.
All three testsuites now pass on their own.
(The first attempt at this blamed a missing CONFIG constant. It made no difference, and the
guard was removed again rather than left in as noise.)
Where the suite stands¶
481 s delivered. ./dockertest is 6:58 against 17:02 at the start, and
--no-coverage 4:01 against 14:58.
Fixed¶
UserTokenManagementCharacterizationTestbuilds its schema once per class.ApikeyCharacterizationTestdropsapplicationsbefore creating it, so--testsuite 'Characterization Tests'passes on its own.QueueControllerMySQLTestdiscards the database singletonparent::setUp()built before its settings were loaded, so--testsuite 'Integration Tests'passes on its own.
The guide described an API nobody had built¶
Pramnos_Authorization_Guide.md documented Gate::define(), policy classes, auth()->can()
and an AuthorizationException. None of it existed. A consumer found it by doing what the
documentation asks — picking a page by its use_cases and building on the first API it named.
The guide now describes what ships. And what ships now includes the gate, because the design was right and the gap was real.
How this one was worse than a normal doc bug¶
Sweeping every guide for \Pramnos\… names that resolve to no file finds nine pages. Eight
are namespace slips — the class exists, the guide spells its path wrong:
| Guide says | Actually |
|---|---|
Pramnos\Testing\TestCase |
Pramnos\Framework\Testing\BaseTestCase |
Pramnos\Application\Response |
Pramnos\Http\Response |
Pramnos\Database\Model |
Pramnos\Database\OrmModel and friends |
Annoying, and self-correcting: the reader greps the class name, finds it one namespace over, moves on.
The authorization guide was the only one where there was nothing to find. No Gate, no
policy authorization, no authorize(), no exception — under any namespace. A reader who greps
Gate and gets nothing back cannot tell "I searched wrong" from "this does not exist", and
that is the state in which somebody keeps looking for another hour.
There is even a near-miss to walk into: Pramnos\Policy\PolicyEngine and PolicyRecord exist,
and execute data-retention policies — retention windows, aggregate refresh, compression.
Same word, unrelated concept. Grep for "policy", find them, conclude the guide is implemented.
The filing put the cost precisely: the consumer's own CLAUDE.md says "if something looks
missing, read the guide first — three times now the conclusion 'the framework does not do
this' was wrong." That instruction is correct and has paid for itself. It also means a guide
describing an API that does not exist sends a reader the other way with the same
confidence, and it lands on whoever is doing authorization work — which is precisely where
people reach for a framework instead of inventing something.
What was actually there¶
A permission store, and four places that ask it:
Pramnos\Auth\Permissions—allow(),deny(),isAllowed()overauthserver.permissions, with a genuinely well-judged three-valued answer: allow, deny, or no rule at all, which is not the same as a denial;Controller::auth()with$actions_authand$action_permissions;ApiCrudController::authorize();- route permissions via
Router::hasPermissions(); - permission-gated navigation in
NavRegistry.
All real, all documented now.
And the gate, because the gap was real¶
The store records what an installation has granted. It cannot express a rule: "the author, or a moderator" is not a row, and written as rows it becomes one row per article per user.
use Pramnos\Auth\Gate;
Gate::define('update-post', fn ($user, $post) => $user->userid === $post->userid);
Gate::policy(\App\Models\Post::class, \App\Policies\PostPolicy::class);
// "an administrator may do anything", once, instead of at the top of every rule
Gate::before(fn ($user) => $user->isAdmin() ? true : null);
Gate::allows('update-post', $post);
Gate::authorize('update-post', $post); // throws AuthorizationException
Gate::forUser($other)->check('update-post', $post);
$this->cannot('update-post', $post); // in a controller
Rules return true, false, or null for no opinion — which falls through rather than
refusing, the same three-valued idea the store already used. Policies may carry their own
before()/after(), narrower than the global hooks.
The bridge, which is the point¶
With it on, an ability shaped resource.privilege that no gate or policy claims is answered
by the store. Rules that need reasoning live in code; everything else stays data an
administrator can edit without a deploy; and one Gate::allows('reports.export') asks
whichever layer owns the answer.
Off by default and deliberately explicit: a gate that silently consulted a database for names nobody registered would be a gate whose answers cannot be read off the code.
One failure shape instead of three¶
Pramnos\Auth\AuthorizationException — code 403, carrying getAbility(). Before it, the
same answer had three shapes: Controller::auth() returned false, ApiCrudController::
authorize() returned false, and the router threw a plain \Exception with code 403. The
router now throws the typed one; since it extends \Exception with the same code, every
existing catch (\Exception $e) and getCode() === 403 check keeps working.
Three things differ from what that page described¶
| That page | Today | Why |
|---|---|---|
$this->authorize('update', $post) |
$this->can() / cannot(), or Gate::authorize() |
ApiCrudController::authorize(string $action): bool already exists with a different meaning — two authorize()s in one hierarchy is a trap even where PHP allows it |
a global auth() helper |
Gate:: statics |
the framework already has three unrelated auth() methods; a fourth spelling would have been the worst of them |
| policies only | policies and the store bridge | the store was already there and already used; a gate that ignored it would have split authorization in two |
And a third isolation extension, written up front¶
Gate keeps abilities in statics. A Gate::before(fn () => true) left by one test would
allow everything for every test after it — and the failure would land in a test asserting that
an ordinary user is refused, which is the assertion nobody expects an unrelated file to
affect.
Pramnos\Framework\Testing\GateIsolation resets it between tests, and it is registered in
phpunit.xml and in what pramnos init generates. This is the first of the three that was
written with its feature rather than after the failures; the other two cost 135 and three.
The check worth stealing¶
The filing ends with the method that found this, and it is the useful part:
for each guide, take the first class or function it names and grep the source for it
It found this page by picking one from its use_cases frontmatter — the selection method the
consumer's own instructions prescribe — and then verifying the first API it named. That last
step is the one nobody performs.
Added¶
Pramnos\Auth\Gate— abilities, policies,before/afterhooks, an optional bridge to the permission store.Pramnos\Auth\AuthorizationException— one failure shape, code 403, naming what was refused.Controller::can()/Controller::cannot().Pramnos\Framework\Testing\GateIsolation, registered in the framework'sphpunit.xmland in generated projects.
Fixed¶
- The Authorization guide describes what the framework
actually does, including a note on
PolicyEnginebeing a different thing entirely, and keeps a record of what the page used to claim. RouterthrowsAuthorizationExceptioninstead of a bare\Exception— same class hierarchy, same code, now recognisable.
The widget area that rendered nothing¶
Correcting the theme guide turned up two things that were not documentation problems.
renderWidgetArea() returned an empty string always — the render loop was commented out.
And displayMenu() called a class from a deprecated CMS that the framework does not ship, so
it fatalled in every project without it.
Both extension points now exist, and an application that uses neither pays nothing for them.
What was actually there¶
public function renderWidgetArea($widgetArea, $args = array())
{
// …
foreach ($widgets as $widgetData) {
// $widget = pramnos_theme_widget::getWidget(array_merge($args, $widgetData));
// if (method_exists($widget, 'display')) {
// $return .= $widget->display($widgetData);
// }
}
return $return;
}
A theme could register widget areas, store widgets in them from an admin screen, ask for an area — and get an empty string. The class the loop needed was never in the framework.
displayMenu() was worse, because it was not silent:
Unqualified inside namespace Pramnos\Theme, that name resolves to that namespace, so it
could not be satisfied even by a project carrying the global class. Asking a theme for a menu
was a fatal error. The framework's own test had to eval() a fake class to test the method
at all, which is the kind of thing a test suite does instead of telling you.
Widgets¶
use Pramnos\Theme\Widget;
class LatestPosts extends Widget
{
protected function content(array $args): string
{
return '<ul>…</ul>';
}
}
$theme->widgets()->register('latest-posts', LatestPosts::class);
Widget handles the wrapping every theme passes — before_widget, before_title — and leaves
you the body. WidgetInterface is there for a widget that wants to own its wrapper.
WidgetRegistry maps a stored record's type back to a class, with a factory form for widgets
that need their settings at construction.
Two decisions worth stating:
A widget with nothing to say renders nothing at all — no empty wrapper, no stray heading. An
empty <h3></h3> is worse than no heading: it appears in the document outline and announces a
section with no name. And it means a theme can test whether an area produced anything rather
than asking each widget in advance.
A stored record whose type is no longer registered is skipped. Widget records outlive the
code that renders them — a plugin is removed, a type is renamed — and a sidebar must not take
the page down over one stale entry. Those types are collected in
$theme->widgets()->unresolved(), so a sidebar quietly missing one of its four widgets is
findable instead of a puzzle.
Menus¶
The framework has no menu storage, and inventing one would have been the wrong answer: every project that has menus has its own table. So a theme says where items come from.
MenuWalker renders them. It is a pure function of its inputs — items in, string out, no
database, no application — so it can be unit-tested, and a theme can subclass it to change one
method rather than reimplement a nav menu.
It accepts alternative key spellings (name/label for title, link/href for url,
submenu/items for children) because menu rows come from tables that predate it, and
renaming a column is not a reasonable price for rendering a list. An item with no URL renders
as a <span>, not an anchor with no href. The legacy [URL], [TITLE], [ACTIVE] and
[HASSUB] markers in the documented displayMenu() defaults are honoured, so a theme passing
those defaults gets sensible markup.
With no provider, displayMenu() returns an empty string rather than failing. A theme
asking for a menu that has no source should render a page without a menu.
What this costs an application that uses neither¶
The constraint this was built to. Asserted as behaviour, not intention:
- the stored-widgets setting is read on first use, not when a theme is constructed. It used to be read in the constructor, so every page of every application paid for it;
- the registry and the walker are built on first use — a project that registers no widgets and displays no menu never constructs either;
renderWidgetArea()on an area with no stored widgets returns after one array lookup, without touching the registry. There is a test that reads the theme's internal registry property afterwards and asserts it is still null;- no table, no migration. Widget records live in the theme's existing settings.
The bug that made this worth a test¶
Moving the settings read out of the constructor introduced one, and a characterization test
caught it before it shipped. addWidget() serialises the whole collection back to the
setting:
$this->widgets[$widget['widgetId']] = $widget;
Settings::setSetting('theme_' . $this->theme . '_widgets', serialize($this->widgets));
Adding to a collection that had not been loaded yet would persist only the new widget and silently discard every widget already stored. Nothing would report it — the widgets would simply be gone the next time an area rendered. Mutators now load first, and there is a test that adds a widget to a theme with one already in its settings and asserts both survive.
The characterization test that caught it was itself order-dependent: it asserted a count of zero after a rejected add, which was only ever true because a theme built without its constructor never loaded anything. Its helper now marks the collection loaded, which is the state a constructed theme is in — so the class stops depending on what a sibling test persisted.
And the deprecated CMS is gone¶
Everything referring to pramnoscms and its siblings has been removed rather than
accommodated, which surfaced three more live problems:
Theme::getThemes()could never have run. It calledpramnos_theme::getTheme(), which inside its own namespace resolves to nothing. It isself::getTheme().- Every user without an avatar got a 404.
avatarurlfell back tomedia/img/pramnoscms/noavatar.jpg— a path into a deprecated CMS's assets, for a file the framework has never shipped. It is now thedefaultAvatarUrlsetting, empty by default, so a template can render initials or an inline SVG instead of an image the framework cannot supply. l()was declared twice and sometimes not at all. It chose between the framework's Factory andpramnos_factory, and was skipped entirely if apramnos_themeclass existed — leaving the helper undefined for the one kind of application the guard was written for.
Also removed: tests/stubs/pramnos_factory_stub.php, loaded unconditionally on every test run
to stand in for a delegation Auth stopped doing (there is already a test asserting the source
no longer mentions it), and two dead commented-out pramnos_html_form lines.
The names still appear in this repository, but only in the comments explaining what was taken out.
Added¶
Pramnos\Theme\WidgetInterface,Pramnos\Theme\Widget,Pramnos\Theme\WidgetRegistry— widgets that actually render.Pramnos\Theme\MenuWalker,Theme::setMenuWalker(),Theme::setMenuItemsProvider().Theme::widgets()— the type registry, built on first use.
Fixed¶
renderWidgetArea()renders the widgets in an area instead of returning an empty string.displayMenu()no longer fatals in a project without a deprecated CMS class.Theme::getThemes()no longer calls a class name that cannot resolve.- A user with no avatar gets the configured default, or nothing, rather than a broken image.
l()is declared once, over the framework's own Factory.- The Theme guide describes all of this, including what it costs a theme that uses none of it.
maxRuntime was a range, and it read like a number¶
An SSE stream configured to run for 95 seconds ended somewhere between 95 and 115, and where depended on how busy the channel was. A client using the same period for its own reconnect therefore lost the race — and lost it exactly on the busy installs.
It now ends at 95, and the stream tells the client when to hand over.
The range¶
A driver checks the deadline at the top of its loop and then blocks for readTimeout seconds
(readTimeout = max(1, pingInterval)):
while (true) {
if ($deadline !== null && time() >= $deadline) {
break;
}
$entries = $connection->xRead($cursors, 0, $options->readTimeout * 1000);
…
}
A deadline that falls during a read is not noticed until that read returns, so the stream
ended in [maxRuntime, maxRuntime + pingInterval]:
| Channel | Deadline noticed | Close landed at |
|---|---|---|
| Busy — an event arrives just after the deadline | on that event | ≈ maxRuntime |
| Idle — nothing arrives | at the next read timeout | up to maxRuntime + pingInterval |
RedisStreamDriver, RedisDriver and DatabaseDriver all had it.
Why a range was worse than a wrong number¶
A client doing an overlapping reconnect — open the replacement, retire the old one once the
replacement proves itself — must hand over before the server closes, and had only maxRuntime
to go on. The obvious reading of the parameter gives you the client's period, and the obvious
reading is the one that loses:
- the client's clock starts at
open, strictly after the server started its own; - at equal periods the server therefore leads by however long the connection took;
- and it wins on the busy installs, where the close lands at the bottom of the range.
The failure is quiet, which is the worst part. The scheduled close arrives as a transport error,
the client backs off, everything recovers — so it presents as an occasional network blip that
gets worse under load. One consumer ran a 95-second client timer against maxRuntime: 95, under
a comment claiming it was "ahead of" the ceiling, and survived only because that panel's
channels were quiet enough that the server usually took the top of the range.
The fix, in two parts¶
The last read is clamped. SubscriptionOptions::blockingWindow($deadline) returns
min(readTimeout, deadline - now), never less than 1, and every blocking driver uses it. The
stream ends at maxRuntime regardless of traffic — which is what the parameter always read
like. One implementation rather than three, and a test that reads all three drivers' source and
fails if any of them goes back to the raw timeout, because the way this regresses is somebody
fixing one and not the others.
The stream says when to hand over. A client should still leave itself a margin, and now it does not have to guess a constant it cannot see:
source.addEventListener('stream-info', (e) => {
const { max_runtime, ping_interval, handover_after } = JSON.parse(e.data);
setTimeout(() => beginHandover(), handover_after * 1000);
});
handover_after is maxRuntime minus a tenth of it, bounded to 2–10 seconds, and never more
than half the runtime — so a four-second stream still advises something sane instead of
"reconnect immediately". Sent as its own event, so a client that has never heard of
stream-info is unaffected: EventSource dispatches by name. An unlimited stream sends none,
because there is no handover to schedule.
The filing offered the docblock alone as its cheapest option. Both of the others were cheap too, and a number that is a number needs less documenting than a range that has to be explained.
Two MCP tools that answered with errors¶
Reported and confirmed in the same round: of the five tools mcp:serve advertises, two could
not answer. Neither failure was visible from outside — the server starts cleanly, lists all
five, and both failures arrived as ordinary results with an error key or a database message
inside content[0].text, so isError was false on both. That is a shape worth remembering:
an MCP tool that returns an error as a result is invisible to anything watching for failures.
route-list returned {"error": "No router available"} on every call. The MCP server is
launched by mcp:serve, so the application behind it is the console kernel, which never
builds a router — routing is an HTTP concern. That branch was not a defensive fallback; it was
the tool's entire behaviour on its only reachable path.
It now builds a router and discovers #[Route] attributes, which need no HTTP request — that
being the point of attributes. The directories come from the PSR-4 map in the project's own
composer.json rather than an assumed src/Controllers, because that map is what the
autoloader uses. Routes registered inside a routes.php that dispatches at the end still
cannot be listed — including that file would serve a request rather than describe one — and the
error now says so, along with where it looked.
query-schema returned ERROR: column "conname" does not exist. conname is a
pg_constraint column and the query reads information_schema; it is tc.constraint_name.
There was already a test asserting that query used information_schema.table_constraints, and
it passed: it checked the right table and never the column, which is exactly how the
wrong column survived it. There is now one that checks the column, and refuses a bare conname.
Also, from running a subset¶
--filter ForeignKeyGuardMigrationTest failed on its own with
relation "users" does not exist, while the full suite passed — the class alters users and
never created it. It creates a minimal one when absent now, and never drops it, because other
classes own richer versions.
That is the third order-dependency this work has turned up, and the reason to keep fixing them is unchanged: the point of running one class is to narrow down a failure, and narrowing must not change the answer.
Fixed¶
- Drivers clamp their last blocking read, so a stream ends at
maxRuntimerather than up topingIntervalseconds later. See the Realtime guide. route-listdiscovers attribute routes instead of reporting that the console has no router.query-schemaselects a column that exists ininformation_schema.ForeignKeyGuardMigrationTestno longer depends on another class having createdusers.
Added¶
- A
stream-infoevent carryingmax_runtime,ping_intervalandhandover_after. SubscriptionOptions::blockingWindow().
An empty ban list is still a ban list¶
QueryBuilder::getAll() answers [] for a failed query as well as an empty table, which its
own docblock presents as the feature it is. A consumer renamed their settings table away, got
array() with nothing thrown, and cached that as the installation's configuration — every
feature toggle at its compiled-in default for the whole TTL, nothing in the logs.
What the filing got right, and the part it corrected itself¶
The report arrived as "a caller cannot tell a failed query from an empty table", and the author
then corrected it — in the direction worth correcting: get() keeps the distinction.
$r = $db->queryBuilder()->from('url_blacklist')->select(['pattern'])->get();
// false when the query failed; a Result when it succeeded, including when it matched nothing
Everything a caller needs is already there. What getAll() and pluck() do is collapse the two,
and that is documented rather than defective.
The sharp edge is where those helpers get reached for. getAll() is the obvious way to read a
list, and the lists whose empty answer is most plausible are the ones where it is most
consequential — settings, permissions, bans, allowlists. A ban list that failed to read is an
empty ban list, and one cache call later it is a cached empty ban list, outliving the failure
that caused it.
And a refinement neither of us had¶
Verifying it turned up something the filing could not have seen from one engine: the ambiguity is PostgreSQL-only.
With throwOnError off — the default — a failed prepare returns false on PostgreSQL and
throws mysqli_sql_exception on MySQL. That per-driver split is documented framework
behaviour, and it means:
A missing table, via getAll() |
|
|---|---|
| PostgreSQL | [] — indistinguishable from an empty table |
| MySQL | throws |
So getAll() was never ambiguous on MySQL, the reported outage was on PostgreSQL 17, and an
application developed against one engine and deployed against the other gets a different failure
mode for free. Both halves are now asserted against real databases — a MySQL class and a
PostgreSQL one — including a test that reproduces the ambiguity itself, so the day somebody
changes it, something says so.
getAllOrFail()¶
The same as getAll() with the one distinction it discards put back: an empty table still returns
[], and a failed query throws QueryException carrying the SQL.
It wraps whatever the driver did into that one type, rather than only checking for false —
which is the whole value, given the split above. One catch (QueryException) works on both
engines without turning on strict mode for the entire connection.
Per-call rather than a mode, because a single dangerous read should not have to make everything
strict. $db->throwOnError = true remains the right answer when a whole process should be loud,
and it already existed.
The line at the point of reading¶
The filing's primary ask was documentation, and it was right to be: the capability existed, the
sentence did not — and it did not exist where somebody reads about getAll().
throwOnError was thoroughly documented in the Database API guide's error-handling section,
which is not where anybody is standing when they reach for a convenience read.
The Query Builder guide now says it beside pluck(): what
[] can mean, which engine it can mean it on, why the danger tracks the kind of list rather
than the method, and the three ways to keep the distinction.
One framework-side instance of the same shape¶
Auditing the framework's own getAll() calls, most are admin list views — an empty list there is
a visible symptom rather than a silent one. Two were not:
DeferredWriteQueue::tablesWithPendingRows() and pendingBatch(). A failed read there answers
"nothing pending", process() loops over nothing, and the run reports success. A queue that
cannot read its own table must not say it drained. Both use getAllOrFail() now.
Added¶
QueryBuilder::getAllOrFail()—getAll()for callers that would rather throw than branch, with one exception type on both drivers.
Fixed¶
DeferredWriteQueuefails loudly instead of reporting an empty drain when it cannot read its own table.
Documentation¶
getAll()'s andpluck()'s docblocks say what[]can mean and which engine it can mean it on.- The Query Builder guide carries the same at the point the helpers are introduced.
The ingest dropped the id it had just read¶
RedisStreamSocket read each entry's id, used it to advance its cursor, and threw it away. So a
WebSocket worker could not tell when an event was published — while an SSE stream could, from
the same backplane, because SseWriter::stream() has always passed the id to onEvent.
The interesting part is why that stayed invisible, and what it made impossible.
The asymmetry¶
// SSE — the id arrives
$sse->stream($driver, $channels, function ($channel, $event, $payload, $w, $id) { … });
// WebSocket — it did not
$server->useIngestRouter(function ($channel, $event, $payload) { … });
Same events, same Redis stream, two transports, and only one of them could date what it received.
Why it stayed invisible, and why that is the dangerous part¶
An ingest with no cursor starts at $ — new entries only. A worker that never replays never sees
an old event, so nothing is ever stale and the missing id costs nothing.
Persisting cursors() is exactly the change that breaks that, and persisting them is the
whole advantage of reading a stream rather than subscribing to one: a worker restarted mid-deploy
with SUBSCRIBE misses everything published while it was down, while one reading from its last id
is handed the gap. The framework documents that as the reason to use it.
So the two were mutually exclusive. Turn on the feature the guide recommends, and every WebSocket client at once — the transport most listeners are on — receives the deploy window as fresh events.
For a durable event that is correct and desirable. For an ephemeral one it is not, and the reporting application had the case that makes it concrete: a typing indicator carries no timestamp of its own, and a chat client naturally sets its state from receipt time. A replayed cue announces that somebody is typing who stopped minutes ago.
The fix¶
The entry id travels with the message, and the router receives it fourth:
$server->useIngestRouter(
function (string $channel, string $event, $payload, ?string $id = null): array {
if ($event === 'typing' && $id !== null) {
$publishedAt = (int) explode('-', $id)[0]; // "<ms>-<seq>"
if ($publishedAt < (int) (microtime(true) * 1000) - 10_000) {
return []; // too old to mean anything
}
}
return [[$channel, $event, $payload]];
}
);
Passed last and defaulted, exactly as the filing asked, so a router written with three parameters keeps working — PHP does not object to an argument a closure has not declared.
null rather than an empty string when the ingest has no notion of one: RedisSubscriberSocket
is pub/sub and has no entry ids, and a router must be able to tell "no such thing here" from a
position it might mistake for one. There is a test for each.
What this cost to find, and what found it¶
Nothing in the framework's own tests would have caught it. The ingest was returning a correct
['channel' => …, 'message' => …] envelope; nothing was broken, an id was simply absent from a
structure that had never carried one.
What found it was an application asking for a feature it could not have — cursor persistence — and working out why it could not have it. The report arrived with the mechanism, the invisibility condition, and the exact signature to add. Worth recording as a shape: the most useful bug reports are about the thing you could not build, not the thing that broke.
Fixed¶
RedisStreamSocket::drain()includes each entry'sidin the message it returns.LocalBroadcastServerpasses it to the ingest router as a defaulted fourth argument.RedisIngestInterfacedocuments the field as optional, so an implementation without ids stays valid.- The Realtime guide shows the ephemeral-event case beside the cursor-persistence advice that used to conflict with it.
Which rule said no¶
The toolbar's Auth tab answers who the request is and what convinced the server of it. Nothing answered the next question — was the action allowed, and which rule decided — and that was not an oversight. It is a property of the feature.
Why nothing could tell you¶
A gate's rule is a closure in a bootstrap file, so it appears in no stack trace. A
Gate::before() hook that returns true skips every step after it and leaves no mark. The SQL
panel cannot help, because a decision may touch no database at all. And a 403 tells you that
something refused, not which of six steps did:
| Step | Means |
|---|---|
before |
A global hook decided immediately — "an administrator may do anything" |
ability |
A named Gate::define() rule answered |
policy |
A policy method answered; the row names it |
store |
The permission store answered, via fallbackToPermissions() |
default |
Nothing claimed this ability, so it was refused |
after |
A rule answered and an after hook overrode it |
The Authorization guide has said since the gate shipped that this order is the contract — that every "why was this allowed" question is answered by knowing which step decided. It was true and unobservable.
The row that earns the tab¶
allowed update-post policy PostPolicy::update App\Models\Post
allowed see-menu ×40 before a global before() hook decided —
refused updatePost default nothing claimed this ability App\Models\Post
fallbackToPermissions() is off by default, so an ability nobody defined is refused — which
makes a typo in an ability name indistinguishable from a deliberate deny, because both produce
false. updatePost where the code defines update-post is a real afternoon.
default separates them, and the collector counts those rows separately so a badge says so
before the tab is opened. It is the same instinct as WidgetRegistry::unresolved(): the thing
that quietly did nothing should be findable, not merely survivable.
Identical checks collapse into ×N, because rendering a permission-gated menu asks the same
question for every one of forty items and that should be one row rather than forty.
What it deliberately does not carry¶
The arguments. A policy check receives whole models, and this payload is attached to the
response — it sits in a browser's network log. So a subject is reduced to its class name and a
user to an id; nothing that came out of a database travels. That is the rule AuthCollector
already applies to the credential it exists to explain, and the reason it exists there is the
reason it applies here.
And it is not a permissions browser. It shows what this request decided, not what a user may do in general. The second question belongs to the permission store and a different tool; a request-scoped panel that drifted into answering both would answer neither well.
What it costs an application that never opens it¶
One boolean check per decision. Gate::enableDecisionLog() is opt-in and the debug provider
calls it, which is exactly the shape Database::enableQueryLog() has had all along — the query
panel does not exist unless something asked for it either. There is a test that switches
recording off and asserts nothing is recorded, because a cost guarantee nobody checks is a cost
guarantee that drifts.
The log is capped at 200 distinct decisions. A page checking hundreds of different abilities has a different problem than this panel is for, and filling memory to describe it would be the wrong trade.
Added¶
Gate::enableDecisionLog(),Gate::decisionLog(),Gate::clearDecisionLog().Pramnos\Debug\Collectors\GateCollectorand a Gate tab, beside Auth — the two halves of "why did this fail", in consecutive tabs.
Documentation¶
Four corrections from the other side of the boundary¶
A consumer adopted the last three releases and sent back what happened. One item is a correction to this documentation: a benefit claimed without the condition it depends on. Two are shapes worth naming that no framework test would ever find. One is a hazard in a base class that shipped without its warning.
1. Cursor persistence is worth nothing when subscribers do not outlive the ingest¶
The Realtime guide said, and had said since RedisStreamSocket shipped:
A worker restarted mid-deploy with
SUBSCRIBEmisses everything published while it was down, while one reading from its last id is given the gap.
True, and incomplete in the way that matters. Replay is worth it when the subscribers outlive the ingest. For an SSE endpoint — one process per client — they do: the client reconnects and resumes. For a WebSocket worker that owns the listening socket, they do not: a restart drops every client with it, so the backlog is replayed into the same empty room the events were published into while it was down.
The consumer measured it rather than taking the paragraph at face value, and came back with a negative result: their clients also re-read their state on every reconnect, because WebSocket carries no initial snapshot, so the gap was already closed from the client side. Persisting cursors would have added supervisor state, a stale-cursor failure mode and a backlog to filter, to deliver events to nobody.
They kept the cue filter the entry-id fix made possible — one comparison, and it makes any future replay safe rather than something to remember — and turned their tripwire into an ordering guard: the filter must exist before a backlog can.
The guide now states the condition, with a table of the two cases. The framing was this side's, and so is the correction.
2. A try/catch around a call that does not throw is a comment¶
The most useful thing in the report, and it is about code that had already got the decision right:
try {
$patterns = $db->queryBuilder()->from('url_blacklist')->getAll();
} catch (\Throwable $e) {
// a membership failing open grants somebody another station's tools, so this denies
return self::DENY;
}
return $this->cache($patterns); // ← an unreadable table arrives here, as []
getAll() does not throw on PostgreSQL, so the catch is unreachable and the failure walks
into the branch that caches a miss. Of the eight reads of this class they found, six already
had a catch written for exactly this failure — one arguing the fail-closed direction
explicitly, in a comment.
Their summary is the sentence to keep: the week's work was less about deciding correct behaviour than about making decisions somebody had already taken actually run.
The Query Builder guide now names the shape, and says to
treat such a catch as a signal rather than a guard: somebody knew this could fail and
which way it should go, so check what the call actually does on failure.
The same sweep found one that was not a list at all: ensureLaunchLicence(), where an
unreadable table read as "this station has no licence" and a second one was written.
Corrected 2026-08-14, after this was first published. The report added that there was no
unique constraint behind the idempotence, and this page repeated it as fact. There is one —
uq_licenses_one_current UNIQUE (station_id) WHERE ends_at IS NULL, checked against the live
database — and they corrected their own docblock, which had it the wrong way round. The read is
still the defect; the missing constraint was not. Left visible rather than quietly edited,
because a page about repeating claims without checking them should not do it silently.
3. A channel whose safety rests on the authorizer, not its name¶
Their private-admin-notifications is a bare literal where every public channel beside it
rebuilds its name with a station id. They checked before reporting it, confirmed it is not a
leak today because the authorizer requires a platform admin — and then noted that their own
roadmap direction, admitting station owners, would put every station's reports in every
station's panel.
The reason that is worth documenting rather than fixing: the two facts live in different files. Whoever widens an authorizer is reading the authorizer, not the worker that chose the channel name. The guide now says to note the dependency where the channel is broadcast and pair it with the authorizer in a test.
They also offered the general form, from a related find: where a transport cannot carry
something, look for every mechanism that assumed it could. EventSource cannot send headers,
so a header-based scope silently did nothing — and applying that rule immediately found a second
stream with the same gap. That sentence is now in the guide too.
4. Service::database() shipped without its warning¶
They adopted Pramnos\Application\Service and caught a hazard in it that this side had not
written down. The lazy fallback is Factory::getDatabase(), which is right for a service written
against the base and wrong for one being moved onto it: a class that previously reached its
database some other way changes which database it talks to the moment its constructor is left
defaulted. Nothing reports it, and every query still succeeds.
The numbers are why it now has its own section: 59 call sites constructed the class they were
converting, and it had been built on an application-level getInstance(). Had the two resolvers
differed, a conversion sold as observability would have repointed all 59. They passed the
instance in explicitly and pinned it with a test.
The Application Styles guide now says to do that, and says to convert selectively — they converted one service out of sixty-five, which is about the right ratio.
What the framework got right, according to the other side¶
Worth recording because it is the part that is easy to lose:
- The engine split ask was answered moot — they run PostgreSQL in both places — but the
sweep it prompted found
isNicknameAvailable()folding a failed read into "nothing found" on all three of its lookups. Three failure paths, all granting: a guest could take a registered account's name, a station persona's, or one somebody was signed in under at that moment. - The Gate tab's
defaultrow was adopted as a rule rather than a feature: they applied the insight to their own authorization layer, which has no gate involved, and now generate the vocabulary and the usage from the source by reflection rather than writing either into a test — "a guard carrying its own copy of the vocabulary only agrees with itself."
Documentation¶
- Realtime guide — when cursor persistence is worth it, and channels whose safety is the authorizer's.
- Query Builder guide — the unreachable
catch. - Application Styles guide — converting an existing
class onto
Service.
"Minor variable name changes"¶
That is the commit message. March 2020, 14 insertions and 90 deletions, and among the deletions were eleven script and style registrations that templates enqueue by handle. An unregistered handle throws, so a port that had been blocked for a day turned out to be blocked by a diff from six years ago.
What was reported¶
A consumer migrating from the legacy framework found that Document::__construct() no longer
registers slimbox2, thickbox, spectrum or the Spry* family. Their evidence was specific
rather than general, which is why it was actionable:
app/themes/admin/default/theme.html.php:15-16callsenqueueScript('slimbox2')andenqueueStyle('slimbox2')with nosrc, and that file is theindextemplate of the admin theme — so it loads on every page of the panel;- an application's own media class does the same with
thickbox; _enqueueScript()throwsCannot find script: <handle>when a handle is neither pre-registered nor given a source.
They re-checked across 36 commits before filing again, and noted that one commit in that range had touched exactly this part of the constructor and made the gap slightly larger.
Three findings, and they have three different answers¶
The eleven registrations were an accident. git log -S puts them in 7b274f8f, titled
"Minor variable name changes". There was no decision to drop deprecated libraries; they were
collateral in a rename. Restored verbatim, at the URLs the legacy framework serves — plus
mediamanager, a twelfth the report had not spotted.
They are restored for compatibility and not on merit, and the code says so. Adobe Spry has been unmaintained since 2012. But a template written when it was current still enqueues it by handle, and a fatal in an admin panel is a worse answer than an old library.
The inputmask handles were deliberate and still wrong. b35d3a3c replaced inputmask 4.0.9
with the 3.3.4 bundle and removed jquery-inputmask-extensions and -date because the
bundle contains them. Correct about the files, wrong about the contract: a template names the
handle. Both handles are registered again, resolving to the bundle.
jquery-inputmask-jui never existed. It is on their checklist among five that were real. Not
in this framework, not in the legacy one — checked in both. There is now a test asserting its
absence, so the next reader does not go looking.
The CDN question, which was the more serious half¶
jquery, bootstrap-datepicker and jquery-inputmask are registered against
cdnjs.cloudflare.com. Everything else in that constructor is local. The consumer asked whether
that was intentional and said that if so it needs documenting as breaking.
It was intentional — 403276b5, "load scripts from cdn", April 2020 — and it was never
documented at all. So an application that upgraded across it silently began loading three
third-party scripts from a third-party host. Their framing is the right one and worth repeating:
- GDPR, for a site with EU visitors: an IP address reaches Cloudflare before any consent is collected;
- CSP: a policy written for a self-hosted application does not list that origin, so the scripts are blocked, not merely remote.
The default stays the CDN. Flipping it would break every application that stopped vendoring those files on the strength of that commit — the same mistake in the other direction. What was missing was the choice and the sentence, not a different default:
serves them from sURL at the paths the legacy framework used. Documented in the
Document guide, including the table of paths.
Why no test caught this for six years¶
Nothing in this repository enqueues slimbox2. The registrations were a promise to other
people's templates, so deleting them broke nothing here and could not have.
There is a test now, and it is shaped around that: one case per handle the constructor promises, generated from a list, asserting each is registered — plus one that enqueues a restored handle with no source the way the consumer's theme does. It also pins the two things that made this expensive:
- the throw happens when the queue is processed, not when
enqueueScript()is called, which is why a missing registration arrives as a broken page rather than a broken template; - the CDN defaults are still the CDN, so changing them is a deliberate act rather than a drift.
A promise made to code you cannot see needs a test you can.
Fixed¶
slimbox2,thickbox,spectrum(script and style),mediamanager, and theSpry*family are registered again.jquery-inputmask-extensionsandjquery-inputmask-dateresolve to the bundle that absorbed them.documentAssetSourcechooses CDN or local for the three CDN-hosted defaults; the CDN move is documented as the breaking change it was.
A blank page is not an error¶
Two findings from two different consumers, both about a path that was right for one project and
silently wrong for another. www was hardcoded in 38 places, and documentAssetSource => 'local'
— which shipped this morning — would have produced two 404s in the first application that tried
it.
--web-root¶
The scaffold writes its document root as www/ by convention. A consumer reported it through the
place it hurt: outDir in the generated vite.config.js was 'www/' . SPA_BUILD_DIR, so a
project served from anywhere else built its front end into a directory nothing serves.
The symptom is a blank page, not an error. The build succeeds, the files are written, and the shell looks for a manifest that is not there. Nothing in any log.
Everything under the document root follows it now — the directory, the front controller,
.htaccess, assets, favicons, the API entry point, the SPA shell and build output, the
.gitignore lines, the Docker DocumentRoot, and the prose in generated files that names the
path. That last one is not decoration: the generated vite.config.js explained itself with
"writes into www/assets/spa/", which would have been a comment naming the wrong directory in
exactly the file somebody reads when the page is blank.
A half-applied option is worse than no option: a project that looks configured, broken in a way
the configuration appears to explain. So the test scaffolds with a non-default root and asserts on
the tree rather than on the flag, including that nothing was left behind in www/. Finding the
last two took a second pass — four literals were written '/www/...' rather than 'www/...', and
a substring search for one spelling does not find the other.
And the option from this morning was the wrong shape¶
documentAssetSource => 'local' shipped a few hours earlier, to answer a
GDPR and CSP concern about three defaults pointing at
cdnjs.cloudflare.com. The consumer who asked for it went to enable it and did not, for a
reason worth more than the feature:
confirmed with
findthat onlymedia/js/jquery/jquery.min.jsexists locally — there is noplugins/directory, sobootstrap-datepicker.jsandjquery.inputmask.jswould 404 if it were switched on today.
All-or-nothing left them choosing between a GDPR problem they wanted to fix and two broken scripts, when what they needed was to fix the one they could. It takes a list now:
'local' still means all three. A comma-separated string and a JSON array are accepted too,
because settings round-trip a list as an array, an stdClass or a string depending on how it was
stored, and three of four spellings producing silence would be worse than not taking a list.
The guide now also says to check the files exist first, with the ls to run. The option changes
a URL and verifies nothing — that is the honest description, and it is the sentence that was
missing this morning.
The third finding was fixed and said so nowhere useful¶
The same filing listed Api::_attachDebugPayload() and Api::_sendServerTiming() as
protected, so a non-Api application cannot feed the SPA debug bar without reimplementing them.
Literally true, and it stopped being the obstacle in 0dba9602. Both methods are now thin
delegations to public statics, and ApiDebugMiddleware calls the same two:
What Api does privately |
The public seam |
|---|---|
_attachDebugPayload($body) |
ApiDebugPayload::attachTo(string $body): string |
_sendServerTiming() |
ApiDebugPayload::sendHeaders(): void |
One line in the pipeline covers every routing style. So no code changed — and that makes this the
more interesting of the three, because the consumer looked in exactly the right place and the
right place did not tell them. Reading Api leads to "protected, cannot use" and stops there.
Two fixes, neither of them behaviour:
- the middleware is now documented in the Application Styles guide, on the page a Services +
API + SPA project actually reads. It was in the Debugging and Upgrade guides, which is the same
mistake as
throwOnErrorbeing thoroughly documented in a section nobody is standing in; - both
Apimethods carry a comment naming the public seam, because somebody reading the protected method is the person who needs it.
Their own ledger had just described this failure mode from the other side — a fix documented
weaker than it is, rotting in the direction nobody watches, because a tripwire checked a name
(is this method still protected) rather than a construction (is the capability reachable). It
is the same shape as the conname test that asserted the right table and never the column.
What all three have in common¶
None was a bug in behaviour anybody could see. One was a path correct in the project that wrote it; one an option whose safe use depended on files the framework cannot know about; one a capability that existed and could not be found from where somebody was reading.
Two were reported by consumers who checked their own filesystem before acting on advice — find
in one case, reading the generated config in the other — and that check is what made them reports
rather than questions. The third is a reminder that "still not fixed" and "still not findable"
produce the same filing, and only one of them is answered by writing code.
Added¶
pramnos init --web-root=<dir>, applied to everything the scaffold writes under the document root, prose included.documentAssetSourceaccepts a list of handles as well as'local'.
Fixed¶
- The generated
vite.config.js,dockernpm,doc.sh,CLAUDE.mdand Svelte entry stub no longer namewww/in prose when the document root is elsewhere.
Documentation¶
- Application Styles guide — feeding the debug
toolbar from a non-
Apiapplication, on the page such a project reads. - Console guide —
--web-root. - Document guide — the per-handle asset source, and the check to run before switching.