Skip to content

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
  • init will 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 update sixty-one times
  • The test database was afraid of losing data
  • TRUNCATE is 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
  • maxRuntime was 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, and logout could 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;
  • whatapiKey, accessToken, the deprecated userAuth header, or a session cookie, reported in the order the middleware checks them so the answer is the credential that will actually be used;
  • where fromaccessToken header versus Authorization: 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

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.js reports every ApiError with 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.svelte wraps each screen in a <svelte:boundary> whose onerror hands 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:

import { reportRoute } from './debug.js';

reportRoute(name, { base: BASE });

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 succeededServer-Timing, X-Pramnos-Debug with 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 servers list 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 apiKey from 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 —

/** @api {get} /status Status */

— 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, $deleteData and $patchData now 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's jsonb ? key operator 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:

InvalidArgumentException: whereRaw() was given 2 binding(s) for 1 placeholder(s) in: a = ?

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 GuidewhereRaw(), 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 — "init will not scaffold over an existing application", and the project: 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:

$pipeline->add(new \Pramnos\Debug\ApiDebugMiddleware());

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 echo reaches 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

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.php making the endpoints overridable. No such file has ever been written: scaffoldSpaAdmin() writes only frontend/screens/Admin.svelte, and the routes instantiate Pramnos\Auth\Controllers\ApiAdmin directly. 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=svelte is not a default. 2026-08-10 — SPA scaffolding presented it as one. The default applies only to an invalid value; with the flag absent, init asks. A non-interactive run must pass it explicitly — which is exactly the kind of thing a script discovers by hanging.

  • create:crud edits two of its outputs rather than writing them. 2026-08-10 — create:crud for a SPA listed src/Api/routes.php and frontend/screens/registry.js alongside 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 own lib/api.js, your own App.svelte survive untouched — you can use it to fill in the pieces you are missing;
  • --force overwrites, 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_dir in app/app.php is honoured, so the front end can live in admin-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

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:

  1. app/app.php's name
  2. the title setting
  3. the TITLE constant
  4. "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.json entry, 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.

php pramnos create:api-client     # → lib/endpoints.js + lib/endpoints.d.ts

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, so node --check passed — and a ReferenceError the first time anybody called it. Every POST in the fixture happened to have a body.
  • An operationId that was already camelCase came out lowercased: listThings became listthings, 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

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.php touches 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:

php -r 'gethostbyname("testhost");'   →  8.00s

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() and resolvedHost() 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

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:

/tests export-ignore

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 init writes an <extensions> block registering both isolation extensions, with a comment saying what they are for so that tidying up does not remove them.
  • RequestIdentityIsolation and DocumentIsolation moved to Pramnos\Framework\Testing, so they exist in a consumer's vendor/.
  • 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

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.

php bin/pramnos init --no-install
  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, skip composer 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

innodb_flush_log_at_trx_commit = 1
sync_binlog                    = 1
log_bin                        = ON
innodb_doublewrite             = ON

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

docker-compose build db && docker-compose up -d db

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.
  • dockertest reports 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.

cost  4:   0.71 ms
cost  8:   9.05 ms
cost 12: 142.9 ms   ← the default

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, with PRAMNOS_BCRYPT_COST for the one environment that should lower it.

Fixed

  • MediaObjectTest builds its schema once per class and empties tables with DELETE.
  • The Testing Guide records the TRUNCATE measurement 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 TRUNCATEmeasured 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}Test converted onto it.
  • TokenTest and UsersControllerTest given the same treatment by hand, since they use the Factory's connection, and stripped of a cacheflush() 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:

  • MessagingModelsMySQLTest and TokenActionMySQLTest ran the framework migrations — every test, from scratch;
  • TwoFactorAuthTest called User::setupDb() plus three migrations;
  • OrmRelationsMySQLTest dropped and created six tables;
  • ModelTest dropped and created four, then dropped them again in tearDown().

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, TokenActionMySQLTest and TwoFactorAuthTest build their schema once per class.
  • MessagingModelsPostgreSQLTest keeps 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

./dockertest --testsuite 'Characterization Tests'

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:

$this->db->query('CREATE TABLE IF NOT EXISTS `applications` (...)');

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

  • UserTokenManagementCharacterizationTest builds its schema once per class.
  • ApikeyCharacterizationTest drops applications before creating it, so --testsuite 'Characterization Tests' passes on its own.
  • QueueControllerMySQLTest discards the database singleton parent::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\Permissionsallow(), deny(), isAllowed() over authserver.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_auth and $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

Gate::fallbackToPermissions();   // off by default

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/after hooks, 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's phpunit.xml and in generated projects.

Fixed

  • The Authorization guide describes what the framework actually does, including a note on PolicyEngine being a different thing entirely, and keeps a record of what the page used to claim.
  • Router throws AuthorizationException instead 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:

$menu = new pramnoscms_menu($this->getMenu($args['theme_location']));

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.

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.

$theme->setMenuItemsProvider(fn ($menuId, $location) => Menu::load($menuId)?->toTree());

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 called pramnos_theme::getTheme(), which inside its own namespace resolves to nothing. It is self::getTheme().
  • Every user without an avatar got a 404. avatarurl fell back to media/img/pramnoscms/noavatar.jpg — a path into a deprecated CMS's assets, for a file the framework has never shipped. It is now the defaultAvatarUrl setting, 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 and pramnos_factory, and was skipped entirely if a pramnos_theme class 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 maxRuntime rather than up to pingInterval seconds later. See the Realtime guide.
  • route-list discovers attribute routes instead of reporting that the console has no router.
  • query-schema selects a column that exists in information_schema.
  • ForeignKeyGuardMigrationTest no longer depends on another class having created users.

Added

  • A stream-info event carrying max_runtime, ping_interval and handover_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()

$patterns = $db->queryBuilder()->from('url_blacklist')->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

  • DeferredWriteQueue fails loudly instead of reporting an empty drain when it cannot read its own table.

Documentation

  • getAll()'s and pluck()'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's id in the message it returns.
  • LocalBroadcastServer passes it to the ingest router as a defaulted fourth argument.
  • RedisIngestInterface documents 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\GateCollector and 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 SUBSCRIBE misses 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 default row 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

"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-16 calls enqueueScript('slimbox2') and enqueueStyle('slimbox2') with no src, and that file is the index template 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() throws Cannot 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:

'documentAssetSource' => 'local',

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 the Spry* family are registered again.
  • jquery-inputmask-extensions and jquery-inputmask-date resolve to the bundle that absorbed them.
  • documentAssetSource chooses 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.

php bin/pramnos init --web-root=public

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 find that only media/js/jquery/jquery.min.js exists locally — there is no plugins/ directory, so bootstrap-datepicker.js and jquery.inputmask.js would 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:

'documentAssetSource' => ['jquery'],   // jquery local; the other two stay on the CDN

'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 throwOnError being thoroughly documented in a section nobody is standing in;
  • both Api methods 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.
  • documentAssetSource accepts a list of handles as well as 'local'.

Fixed

  • The generated vite.config.js, dockernpm, doc.sh, CLAUDE.md and Svelte entry stub no longer name www/ in prose when the document root is elsewhere.

Documentation