Skip to content

24 August 2026

15 changes:

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

Seven filings from a consumer, and the two more they turned up

A consuming application keeps a folder of filings against this framework: bugs, gaps, and guides that described something that did not exist. Seven were open. This is all of them, plus two faults that only appeared because fixing the first seven meant running the tests.

Two of the seven were silent — they produced a wrong answer rather than an error — and those are the ones worth reading first.

Fixed — the silent ones

One apostrophe, and every placeholder after it went unbound

Database::preparedQuery()'s placeholder scanner knew about string literals and not about comments. An apostrophe inside a comment — the possessive in /* a JOIN's clause */ — read as the start of a literal, so every :name after it was left in the SQL unbound. The statement failed, and because preparedQuery() answers false rather than throwing, a caller writing $result ?: [] could not tell that from an empty table.

It dark-screened a "now playing" page, whose only symptom was a sentence that page is entitled to say: nobody has reported a track in the last fifteen minutes.

Writing an integration test for the fix found the other half. prepare() — which every query in the framework goes through — counted its %s/%d placeholders with a quote-aware, comment-blind regex of its own. The same apostrophe hid the real placeholder, and the prepare failed on an argument-count mismatch. Two places were answering where does this stop being SQL with two different, both-incomplete rules.

They now ask one. maskInertSql() returns a same-length copy of the statement with literals and comments blanked out, and both callers read it; prepare()'s two regexes collapsed into a single offset walk over that mask.

The dialects are told apart rather than averaged, because every difference decides whether a placeholder binds:

MySQL PostgreSQL
# to end of line comment operator — still SQL
-- with no space (5--3) arithmetic → 8 comment → 5
/* a /* b */ c */ ends at the first */ nests
/*!40101 … */ executed — placeholders inside bind ordinary comment

The family test asks "not PostgreSQL" rather than "is MySQL", so an unknown driver lands on the stricter rules: wrongly masking live SQL is the worse mistake.

A lookup that wrote, and reverted the preference it found

User::getCurrentUser() promises to say who is signed in. On every call after the first in a request it also compared users.language with the interface language and, when they differed, overwrote the column and saved the user.

The branch was ordinary, not an edge. The first call caches the user on the application, so every call after it landed there — and a page whose theme header asks who is signed in and whose controller asks again reaches it as a matter of course.

Two things followed. users.language reads as the user's stored preference, and this treated it as a cache of the interface language for whoever looked at them last: an operator who chose English in a bilingual admin panel had that choice reverted by opening the Greek-rendered site, silently, and only on the accounts that had used the feature — which is the population most likely to be testing it. And on an account with no email address, ordinary for one an admin created, the save could raise from _save()'s address validation, ending a request over a column nobody had asked about.

The write is gone. Nothing else in the framework writes users.language, so it is now only ever what your application put there. If you want the two kept in step, write the column where the language is chosen — one place, visible in a diff, and it does not fire on an account that was merely read.

Fixed — the loud ones

A translation with a placeholder could not be looked up

Language::_() called sprintf() on every translation it found, whether or not the caller passed anything to format with, and handed it the arguments as a single array. Both halves were wrong and they compounded: looking up a translation containing %s with no arguments was sprintf('%s', []), an ArgumentCountError and therefore fatal on PHP 8; a caller that did pass arguments got Array printed for the first placeholder.

The untranslated path returns the key unchanged and never had the bug, which is what made this expensive — a string worked in development against the source language and answered 500 the day the language file gained the key. The symptom pointed at the language file rather than at the translator.

Now: format only when arguments are given, and with vsprintf, so positional specifiers work and a translation can reorder what its source string did not. A mismatch between a translation's placeholders and a call site's arguments is caught and logged rather than raised — language files are content, edited by translators, and a stray %s must not be able to take a page down.

The i18n guide showed $lang->_('Welcome, %s!', $username) before it worked. It now describes what actually happens.

Added — the HTTP client can stop reading, and can read several things at once

headersOnly() and maxResponseBytes()

Client read a response to completion or to its timeout, and offered no way to say the headers are all I need or stop after N bytes. Against an endpoint that never stops sending — an Icecast mount, an SSE feed, a tail -f over HTTP — those are the same thing, and neither is the answer the caller wanted.

Two measured failures. A live endpoint was reported unreachable: the server answered 200 audio/mpeg in milliseconds and all of it was discarded. And a faster endpoint never reached the timeout at all — three seconds of a fast stream is a quarter of a gigabyte in memory_limit, which is not a recoverable failure.

// Is this stream up, and what is it serving?
$r = Client::get($url)->connectTimeout(2)->timeout(3)->headersOnly()->send();
$r->status();               // 200
$r->header('content-type'); // 'audio/mpeg'
$r->truncated();            // true — we stopped on purpose

// The first 16 kB of an endless body: enough for the ICY metadata block.
$r = Client::get($url)->header('Icy-MetaData', '1')->maxResponseBytes(16 * 1024)->send();

The second is not an approximation of the first: a caller that needs the headers and a bounded prefix had no way to say so, and a consuming application had written the same cURL workaround twice.

Reaching the ceiling is a normal outcome. The response carries a complete status and complete headers, body() holds what was read, and ClientResponse::truncated() says whether anything is missing — it answers is something missing, not was a limit set, so a body that fits, or a 204, comes back untruncated.

There is no default ceiling, deliberately: a default would silently truncate every caller that legitimately downloads something large, and a body that quietly loses its tail is worse than one that fails loudly.

headersOnly() is not head() — a great many servers answer HEAD with 404 or 405 on a path they serve happily over GET (17 of 30 on one catalogue of streaming endpoints), so a prober built on HEAD reports live services as dead.

Client::pool()

Polling a catalogue one endpoint at a time is not a cadence, it is a backlog. 200 status endpoints at ~1.1 s each is 218 seconds for one pass, so a poller promising a thirty-second tier was reaching each station every four minutes and reporting otherwise. Almost all of that second is spent waiting on somebody else's server — exactly the wait that overlaps.

$responses = Client::pool([
    'aroma'  => 'https://one.example/status-json.xsl',
    'kosmos' => Client::get('https://two.example/stats?json=1')
        ->connectTimeout(2)->timeout(3)->maxResponseBytes(64 * 1024),
], concurrency: 8);

Keyed in, keyed out. A failure is a value — a dead host's entry is a ClientException in the array and the pool itself never raises, because half these endpoints are down at any moment and one of them must not abandon the other seven. Per-request options come from passing a configured Client; retry() is honoured in rounds; fakes work, so a test of a batching caller does not quietly become a live network test.

It is a facade over curl_multi, not a second transport: execute() was split so a pooled request gets the same TLS defaults, redirect handling and header normalisation as the same request sent alone.

Two things fixed on the way

Response headers accumulated across redirect hops, so a redirected request answered with the redirect's Location and Content-Type mixed into the final response's headers. And execute() carried @codeCoverageIgnore"requires a live network endpoint" — which left the only method in the class that speaks HTTP as the only one nothing checked. A forked socket server is a live network endpoint. Client and ClientResponse are now both at 100% line coverage.

Added — the router can say a path exists for another method

getMatchedRoute() answers for the request's own method, so a GET on a POST-only endpoint fell through exactly as a path nobody declared did. An application could only answer 404 for both — honest, and unhelpful: it tells an integrator to check the address when the address was right.

$allowed = $router->allowedMethodsFor($request);   // ['GET', 'HEAD', 'POST'], or []

if ($allowed === []) {
    return $this->notFound();                      // 404
}
header('Allow: ' . implode(', ', $allowed));
return $this->methodNotAllowed($allowed);          // 405

RFC 9110 §15.5.6 makes Allow mandatory on a 405, which you cannot send without knowing the set. It lives on the router because matching a URI pattern against a path — placeholders, optional segments, the query-string forms — is the router's own rule; re-deriving it from getRoutesWithPermissions() would be a second spelling of the matcher.

HEAD is reported wherever GET is, because that is what the router actually does. An Allow header without it would deny a request about to be served.

Fixed — two faults the work turned up

Neither was filed. Both were found by running the tests.

A safety check that raised an error to say "no"

Helpers::checkUnserialize() exists to answer, safely, whether a string is serialized data. It answered by handing the string to @unserialize(). The @ suppresses the notice for output, but the error is still raised — a set_error_handler sees it, and so does anything counting lines in an error log.

The bill arrived when usertokens.deviceinfo began holding JSON instead of an empty string: unserialize('') is silent, unserialize('{…}') raises Error at offset 0, and that column is read on every token check on every request. It now pre-screens on the serialization format's own grammar — every serialized value starts with a type letter and a colon — and the answer is unchanged for every input.

One define() decided the whole test run was "developing"

Two DevPanel test files called define('DEVELOPMENT', true) in setUp(). A constant cannot be undefined, so from that point on every test in the process ran as if the application were in development.

Two middleware tests had grown to depend on it without saying so: they assert that a JWT exception message reaches the client as data, which is true only while developing, and neither arranged that condition. They passed in a full-suite run and failed whenever their own class was run alone — the run somebody makes while working on that file.

Underneath, the branch that matters in production had no test at all. Nothing asserted that the detail is withheld when not developing, and that absence is exactly why the dependency went unnoticed: no test claimed the state mattered, so nothing broke when it silently changed. Both tests now set APP_DEBUG explicitly and restore it, the DevPanel files run in their own processes, and the missing negative test exists.

Three UnifiedAuthMiddleware tests that reach the database also run in their own processes now. Database::getInstance() caches one instance per name in a static built from whichever settings were loaded first, so their bootDatabase() helper could not reach an instance that already existed — under --filter User they inherited another connection and mysqli went looking for a local socket.

Documentation

  • A guide for the HTTP client. It was documented only in 1.2-new-features.md, which is frozen — precisely the shape the docs rules warn about, where a feature exists but the page describing it is one nobody is sent to. Pramnos_Http_Client_Guide.md, in the nav, with use_cases.
  • preparedQuery() was undocumented. The Database API Guide now covers it, including what counts as SQL and what does not, and the dialect table above.
  • users.language is the user's preference, and the framework does not touch it — stated in the Authentication Guide so the removal above is a contract rather than an absence.
  • What the scaffolded SPA API client assumes. frontend/lib/api.js speaks the framework's own API contract — an apiKey header, an accessToken header, /account/login — which an attribute-routed app authenticating with Authorization: Bearer shares none of. The Application Styles Guide now tables what to replace and what is worth keeping. Legitimate divergence, but a reader meeting it should not have to derive that from the code.
  • The i18n and routing guides gained the sections described above.

The Svelte generator catches up with the MVC one

create:crud produced a front-end screen with a text box for every column. The MVC half of the same command, on the same table, produced a checkbox for a boolean and a searchable picker for a foreign key. Four faults nobody had filed turned up while closing that gap, and one of them had been rendering every boolean column in every generated form as a number input.

The complaint

The framework's scaffolding can make Svelte apps, but it is not a complete implementation. In the MVC version there are CLI commands to create every element you might need — I can design a migration from the command line and the system builds the model and a full CRUD with no intervention. The Svelte side does not have the same level of automation. It should.

The finding

Not a missing capability. One call site reaching for the weaker of two methods that both already existed.

createSpaScreen() called editableColumns(), which returns column names. The MVC path called introspectTableAsWizardColumns() on the same table and got the logical type, nullability, the COLUMN COMMENT and every foreign key — then turned them into a checkbox, a <textarea>, a date input and a select.

That is not cosmetic. A text box over a boolean stores the string "on", and "on" is truthy for ever afterwards. A text box over a foreign key asks somebody to type a numeric id they have no way to look up. A text box over a timestamp accepts anything and the insert fails at the database. The generated screen was a demo; the MVC screen was a feature.

Fixed — the generated screen

create:crud thing --table=things in a spa project now produces a screen whose controls match the columns:

Column Control
boolean / tinyint(1) checkbox
text / longtext / json textarea
date <input type="date">
datetime / timestamp <input type="datetime-local">, converted to the space form the database wants
integer / decimal / float <input type="number">, step="any" where fractions are allowed
a foreign key a searchable picker against the referenced resource's own list endpoint

The COLUMN COMMENT becomes the label, NOT NULL becomes required, and the generator's exclusion list still applies — a generated screen does not print a password hash in an admin table and offer it for editing.

A blank nullable field is saved as null, not ''. They compare, sort and COALESCE differently, so a form that cannot express the difference converts every unset optional column to '' on the first save — a data change nobody asked for, invisible until something depends on it.

The list sorts, searches and pages on the server, and its state lives in the URL. A link to "page 3, sorted by listeners" is a link somebody can send, and a background re-read leaves the reader where they were rather than jumping them to page one.

The picker degrades visibly

A foreign-key picker reads the referenced resource's own list endpoint — the one create:crud generates for it — rather than a per-CRUD lookup action, which would mean two lookup surfaces per foreign key with two sets of authorisation.

So it works when the referenced table has been generated too, and says so when it has not: the field falls back to the raw id and names the endpoint it could not read. A picker that silently renders an empty list is indistinguishable from a table with no rows.

Added — the components, and their tests

A screen that imports components a project does not have is a build error arriving several minutes after the command that reported success. So the components ship:

File What it is
components/DataTable.svelte Table and card layouts from one column definition, over the framework's own ApiListResponse::paginated() envelope
components/Pagination.svelte Numbered, windowed, every button keyboard-reachable with a real name
components/ConfirmDialog.svelte Focus trap, Escape, optional typed-phrase mode — replacing window.confirm()
components/Field.svelte The control-per-type renderer above
lib/i18n.svelte.js t() / tHtml(), a client for the framework's own catalogue

DataTable reports and never performs: onsort, onpage and onsearch say what the user asked for and the screen decides. That is what lets one component serve a server-paged list and a local one.

Written once and never overwritten. The whole value of shipping a DataTable is that a project extends it, so a generator that refreshed it would undo that work on the next create:crud. project:resync --spa-components takes a newer version deliberately, and is not part of a plain project:resync for the same reason.

Each ships with its test, into the project's __tests__/. The framework's own JS runner is node --test with no Svelte compiler; a scaffolded project already has Vitest and @testing-library/svelte. So they are tested where they run, in every generated project, rather than nowhere.

Added — the two doors

php pramnos create:screen Dashboard --blank      # a screen with no list
php pramnos create:screen Invoices --table=invoices
php pramnos create:component StatusBadge         # a component *and its test*

createSpaScreen() was reachable only through create:crud, so the documented way to add a dashboard was to generate a CRUD for a table you did not want and delete two thirds of it. create:view exists on the MVC side for exactly that reason.

create:component writes the test beside the component, and that is the point of it rather than a nicety: create:service writes a test stub, which is why services in a scaffolded project have tests, and the front end had no such command, which is why components did not. It is the same lever.

Added — a translation endpoint for the front end

A SPA cannot call _(). Without an endpoint a front end either ships no translation or grows a second catalogue — and a second catalogue means a string that moves between a component and a controller loses its translation, silently, in whichever direction it moved.

scaffold:spa now writes a controller answering GET {apiPrefix}/language, serving the installation's own map, and lib/i18n.svelte.js is a client for it: same key, same fallback, same %s rule as _().

tHtml() keeps the translation's own markup live and escapes everything substituted into it. A translator writing <strong> is trusted; a value arriving at run time from an API or another user is script.

Changed — the router carries state

parse() returns {name, path, segments, query} and the shell passes the route to every screen as a prop. segments is how a detail view knows its record; query is how a list knows its page. go() and href() replace pathFor() + router.link — clicks are intercepted once on the window, so an anchor is just an anchor, and modified clicks, target and download still behave like real links.

api.get(path, query) takes the query as an object and drops blanks, nulls and undefineds. A URL carrying an empty parameter is a different URL from one carrying none — two cache keys for one view — and dropping them centrally is why no screen has to remember an if per parameter and forget one.

If your application calls api.get(path, options) with two arguments, the second is now read as a query. Find them with:

grep -rn "api\.get([^)]*," --include=*.js --include=*.svelte .
create:api-client's generated module composes its own query and calls api.get with one argument, so it is unaffected and needs no regenerating.

Fixed — four faults nobody had filed

All four are older than this work, and all four were found by running the tests for it.

Every boolean column on MySQL was a number input

Database::getColumns() selected DATA_TYPE, which is tinyint. mapSqlTypeToLogical() checks for tinyint(1) to recognise MySQL's boolean convention — and the width lives only in COLUMN_TYPE. So the check could never match, while the code and its comment both said it did.

This affected the MVC generator too, and had for as long as both existed. PostgreSQL reports boolean either way, which is why nobody noticed on that side. COLUMN_TYPE is now selected alongside; Type is unchanged for the callers that read it.

Column order was whatever the server felt like

Neither introspection query had an ORDER BY, and INFORMATION_SCHEMA has no inherent order — so a generated form's fields came back roughly alphabetical rather than in the order the table declares them, which buries the column the record is actually identified by somewhere in the middle. Both queries now order by ordinal position.

getColumns() cached for an hour and nothing invalidated it

The framework's own documented order of work is create:migration, migrate, create:crud. The generator therefore runs minutes after the schema changed, and read a cached answer describing the table as it was before the migration — then wrote a model and a form for the old columns and reported success. The cache store is shared, so the staleness outlived the process and re-running the command did not clear it either.

Generators now read fresh, through an additive $fresh parameter. Request-time callers keep the cache.

spa_source_dir was honoured by one command and ignored by another

project:resync read it; the CRUD generator hard-coded frontend/. A project that had moved its front end got its generated screens written where nothing builds, and a resync that reported them missing. There is one rule now — Init::spaSourceDirFor() — for the three callers that each carried a copy of it. The copy this change was about to add would have answered frontend/ for a build-less project, which is a fourth wrong answer.

Separately, Language::getLanguages() scanned ROOT/language while load() reads app/language first — the layout init generates. So it threw "Languages directory does not exist" on a project whose translations were working, and a language picker had nothing to put in it.

Documentation

Two faults in the handed-over code, fixed before it shipped

The filing came with its own implementation attached, generalised from a working admin panel. Two things did not survive review, and both are worth naming because neither would have failed loudly:

  • Field.svelte's foreign-key resolver was an $effect that read the state it wrote. A reference the endpoint cannot resolve leaves the list empty, the assignment is a fresh array so it counts as a change, and the effect fires again — one unresolvable id was an infinite request loop.
  • Both new commands named their file with getProperClassName($name, false), which pluralises and flattens the rest of the name to lower case. So create:component StatusBadge would have written Statusbadges.svelte — a component nothing imports, under a name nobody asked for. A screen is not a database table.

A flush that cleared nothing, and said it had

A cache category with an underscore in its name could not be cleared. The file adapter wrote its entries into one directory and clear() looked in another, so the flush deleted nothing and returned as though it had worked. Found while fixing something else.

What was wrong

FileAdapter::getFilePath() decided which directory an entry belonged in by splitting its key on the first underscore. Cache keys are built as {category}_{id}.{ext}, so:

Category Key Directory chosen
userlist userlist_<id>.sql userlist
schema_columns_things schema_columns_things_<id>.sql schema

clear($category) builds its path from the category it is handed. So for the second row it looked for a directory called schema_columns_things, found nothing, deleted nothing, and returned success. Every category with an underscore in its name was permanently unclearable, silently, and its entries went on being served until they expired.

Why it stayed hidden

Every category the framework itself uses is a single word — permissions, userlist, usertokens, media, settings, applications — so they all land on the ✔ row. That was checked rather than assumed: those flushes all work, and always did.

What does not is getColumns()'s schema_columns_<table>, added while bringing the SPA generator up to the MVC one. And this guide's own example recommended $cache->category = 'user_' . $userId; — the broken shape — a few sections below the page that explains what a category is for.

Somebody had already met the same parsing from the other side: Cache::_generateCacheName() strips underscores out of the prefix before building the key, for exactly this reason. The category never got the same treatment.

The fix

The adapter is told its category rather than recovering it from the key. AbstractAdapter gained a category, Cache sets it (with the prefix) before every adapter call, and FileAdapter uses it for both the write path and the flush — one value, both sides, no string parsing.

Setting it per call matters: an adapter instance is shared while the category is chosen per call — Database::cacheRead() assigns it right before reading — so an adapter that kept its constructor's category would file everything under whichever one happened to be first.

A flush also sweeps the place the old layout misfiled entries. Without that, upgrading leaves every misfiled entry on disk, still being served until it expires — so the bug would outlive its own fix by exactly the staleness window that made it worth fixing. The sweep matches on the file name, anchored with the same _ the key is built with, so schema_columns_things cannot take schema_columns_widgets's entries out of the schema/ directory they both landed in.

The other adapters were checked, not assumed. Redis, Memcached and the array store all match the category against the key with a separator anchor, so none of them had this. It was the file adapter's directory derivation alone.

And the invalidation it was blocking

Database::getColumns() caches an introspection for an hour, on the stated grounds that schemas rarely change. True — and the moment one does change is exactly the moment somebody asks again, and nothing was invalidating the entry.

The framework's own documented order of work makes that routine rather than a corner: create:migration, migrate, create:crud. Anything reading columns afterwards — a model hydrating its field list, a form builder, an inspector — was answered with the table as it had been an hour earlier. The store is shared, so the staleness outlived the process: re-running the command did not clear it.

Every DDL method on SchemaBuilder now flushes the table it touched: createTable(), alterTable(), dropTable(), dropTableIfExists(), renameTable(). A raw $db->query('ALTER TABLE …') still does not — call $db->forgetColumns($table) yourself if you do that.

Two mechanisms, and they cover different callers:

  • forgetColumns() keeps every ordinary caller correct after a migration.
  • getColumns($table, $schema, false, true) — the $fresh flag — keeps a code generator correct even after a change nobody announced, because a generator runs minutes after the schema moved and a stale answer there produces a model for columns that no longer exist.

Removing either one leaves a real hole, which is why both are tested.

What was measured

Reverting the adapter's category handling reddens the directory-naming test and not the clear() tests — because the legacy sweep catches the misfiled entry either way. That is the two halves working as intended rather than a gap in the tests, and it is written into the test file: a reader reversing one half and seeing greens would otherwise conclude the tests were checking nothing.

Removing the SchemaBuilder calls reddens three of the five schema-invalidation tests.

Documentation

  • Cache Guide — that a category may contain underscores, what went wrong, and the corrected user_<id> example with the flush that now works.
  • Database API GuidegetColumns() was undocumented. It now has a section: the fields it returns, the cache, what invalidates it, and when to pass $fresh.

On PostgreSQL a key was a guess, and a response could not say what it cost

Two filings from the same consuming application, both raised by using what shipped earlier today. On PostgreSQL the generators could not find a primary key or a foreign key — faults older than the generator work that surfaced them, and affecting the MVC generator equally. And a ClientResponse carried no transfer statistics, so a pooled request could not say what it cost.

Fixed — on PostgreSQL a foreign key was never a foreign key

Database::getColumns() computed its ForeignKey flag from information_schema.constraint_column_usage. For a FOREIGN KEY constraint that view lists the column of the referenced table, not the referencing one. So on streams(station_id) → stations(id):

column PrimaryKey ForeignKey ForeignTable
id true true ''
station_id false false stations

Measured, not reasoned: the flag was true on the primary key and false on the actual foreign key, while ForeignTable and ForeignColumn — from key_column_usage, in the same row — were correct. The right data was already there under the right name; only the flag disagreed with it.

It was never true for a foreign key on any table. Everything gated on it therefore saw none:

  • the generated Svelte form rendered a number input where the searchable picker belongs — the headline of yesterday's generator work, unreachable on PostgreSQL;
  • the generated MVC form rendered a bare <input> instead of the select2-over-fkOptions() it has had for much longer;
  • unsigned is decided from the same flag, so generated migrations differed too.

PrimaryKey used the same view and was accidentally right, because for a PRIMARY KEY constraint constraint_column_usage does list the table's own columns. The two looked symmetric while one of them was a coincidence, so both now read key_column_usage — measured identical on single and composite keys, and right for the same reason as its neighbour rather than by luck.

Fixed — and a primary key was a guess

if (($result->fields['Key'] ?? '') === 'PRI'
    || ($result->fields['Column_key'] ?? '') === 'PRI') {

Key and Column_key are the MySQL projection's names. PostgreSQL answers PrimaryKey as a boolean, which this never read — so the loop could never match, and the <singular>id convention was the answer for every PostgreSQL table.

Measured against one application's schema: of 88 single-column primary keys the convention got 3 right.

albums            real: id      guessed: albumid
applications      real: appid   guessed: applicationid
station_streams   real: id      guessed: station_streamid

Why this is worse than a wrong default usually is: the read path never touches the key. The generated screen puts that name in its KEY constant, and every PUT {resource}/{id}, every DELETE and the table's rowKey are built from a column that does not exist. So a generated CRUD lists perfectly and fails on the first save or delete — after somebody has started trusting it. That is worse than failing at generation time.

primaryKeyFor() now reads PrimaryKey too, through a small isTruthyFlag() helper because PostgreSQL's booleans arrive as true, 't' or '1' depending on how the row was cast. The convention stays as the last resort it was meant to be — the schema-first workflow needs it for a table that does not exist yet.

create:view --full on an existing table also asked getSingularPrimaryKey(), which is pure convention and never touches the database. That call site now asks the table. The migration-wizard call sites still use the convention, and should: there the table does not exist and the migration about to be written is what will name its key.

Added — a response says what it cost

$response->transferredBytes();   // bytes over the wire, headers included
$response->elapsedMs();          // how long the request took

libcurl measured both already and the client discarded them.

In a pool, each entry reports its own figures. That is the point. Without them a caller keeping an outbound-bandwidth ledger had only the clock around the whole batch, and dividing it across the requests silently changes the column's meaning from "how slow was that server" to "what share of our elapsed time did this cost". Both are legitimate numbers; having to pick one because the response would not say is not.

Three decisions in it, each from how the ledger is actually read:

  • The headers count. CURLINFO_SIZE_DOWNLOAD is the body alone, so a headersOnly() probe would have reported 0 bytes for a request that really moved bytes — measured at 161 bytes of headers against 0 of body on a local endpoint. Zeroing the byte column of a screen an operator reads is the failure this exists to avoid, and it would have hit precisely the caller that probes rather than downloads.
  • A failure still reports. A 404 with a page of HTML behind it is bandwidth that was paid for, and a 500 with a stack trace is bandwidth and a wrong address. A statistic only populated on success would miss exactly the requests worth finding.
  • null means nobody measured, not zero. A faked response has no transfer to report, and 0 would quietly deflate any total it was added to.

transferredBytes() is deliberately not strlen(body()): they differ under a maxResponseBytes() ceiling, under compression, and by the headers.

Additive, so BC holds, and the pool needed no new API — each entry already returns its own ClientResponse.

What made these findable

Both filings say the same thing about how they were found, and it is worth repeating: a fixture would not have caught either PostgreSQL fault. A hand-built fixture names its columns after the convention, so the key fault is invisible; and the foreign-key fault needs two real tables with a real constraint between them. They appeared the first time the introspection ran against a live schema.

The new tests are integration tests against the Docker PostgreSQL for that reason, and their fixture is deliberately built with keys the convention gets wrong — a table keyed on id rather than <singular>id. One of them asserts that the convention and the real answer differ, so the day they coincide the test says so by failing rather than by quietly proving nothing.

Reverting each half reddens its own tests and not the other's: the foreign-key view reddens four, the primary-key read reddens three.

Documentation

  • Database API Guide — what the flags look like on each driver, and that a reader should accept either spelling of a boolean.
  • HTTP Client Guide — the two accessors, why the headers count, and the pooled-ledger example.

The scanner that cost more than it saved

Asked whether the last few days had undone the test-suite performance work. Measured rather than reasoned, and the answer was two regressions — both mine, both from today, and neither of them visible in the suite total. The measuring then turned up something older and larger underneath.

Fixed — the comment scanner, on every prepared statement

maskInertSql() shipped this morning so the placeholder scanner would stop reading an apostrophe inside a comment as the start of a string literal. It walks the statement one byte at a time in PHP, and it replaced two quote-only regexes.

Statement scanner the regexes it replaced
41 bytes 21.9 µs 0.26 µs
103 bytes 50.6 µs 0.38 µs
150 bytes 74.1 µs 0.31 µs

150 to 240 times, on a path that runs for every prepared statement the framework issues. A page with thirty queries paid about 1.5 ms of pure character-looping for a feature the overwhelming majority of statements never use.

The walk is necessary — where a comment ends is not something a regex can decide while also respecting string literals — but only for statements that contain a comment. With no comment opener present the only inert regions are single-quoted literals, and one regex finds exactly those, same length and same filler. 22–74 µs became 1.3–1.7 µs, of which about 1 µs is the reflection call the benchmark used.

The fast path also requires an even number of quotes: the walk treats everything after an unterminated quote as inert and the regex would not match it at all. Invalid SQL either way, but not the same answer, and a fast path that changes an answer is not a fast path. A test compares the two branches directly rather than trusting that reasoning.

Fixed — the PostgreSQL introspection, on every model save

Yesterday's fix for the ForeignKey flag was correct and four times slower. information_schema.constraint_column_usage gives the wrong column for a foreign key; key_column_usage gives the right one and costs 9.6 ms → 37.9 ms for a single table. Model::_save() runs that query uncached, on every save.

The information_schema views are themselves joins over the catalog, and asking one per column means paying per column. pg_constraint answers the same question directly — conkey holds the attribute numbers of the columns this table constrains, which is exactly the semantics both flags need — and each set is gathered once as an array instead of being correlated per column, so the planner evaluates it as an InitPlan rather than a subplan per row.

The three ForeignTable / ForeignSchema / ForeignColumn lookups moved with them, off a three-way join across referential_constraints and key_column_usage that ran once per column for each of the three fields.

before yesterday's correctness fix 9.6 ms
after it 28.9 ms
now 4.2 ms

So it is right and faster than it was before either change — verified against a real two-table fixture, not a reasoned equivalence.

Fixed — two classes the earlier performance work never reached

Class Before After
TwoFactorAuthServiceMySQLTest 24.7 s / 17 tests 3.3 s
MessagingModelsPostgreSQLTest 22.2 s / 11 tests 17.5 s

Both rebuilt their whole schema in every setUp() — the first re-ran three migrations seventeen times, the second five migrations eleven times. Building a schema is expensive; building the same schema seventeen times is seventeen times as expensive. Emptying it is not.

Neither could use DatabaseTestCase, because the code under test reaches the database through Database::getInstance() and the base class owns a handle of its own. Both got the pattern by hand.

Found, measured, and deliberately not fixed

The second class barely moved, and that is worth more than the seconds it saved. Profiling instead of guessing:

Per test
setUp() in total 3.3 ms
every test, including ones that assert almost nothing 1.0 – 1.9 s

The cost was attributed to the saves. That attribution is withdrawn — see the correction immediately below.

Corrected 2026-08-24. The 268 ms below does not reproduce. Re-measured, the suite's cache resolves to FileAdapter — the fixtures configure no cache method — and a category clear there is 0.05 ms. So this number was not measured where it says it was, and the conclusion that cacheflush() explains the 1.0–1.9 s per test is withdrawn; that cost is currently unexplained. The mechanism described below is real and was fixed the same day — see Clearing one cache category cost the whole database for the measurements that do reproduce (128.7 ms against a 500,000-key keyspace, flat under 1 ms after).

$cache->clear('mails') ~~268 ms~~ — see above
$db->cacheflush('mails') ~~293 ms~~

Clearing a category deleted by pattern — the pattern is narrow, but SCAN with a MATCH still walks the entire keyspace. MATCH filters what comes back, not what is traversed. So clearing one category cost what clearing all of them cost: that part was right, and it is what got fixed.

Model calls cacheflush() on every write: once per save (the category on insert, the record's key on update) and twice per delete. So a save is one full Redis traversal and a delete is two, in production as much as in the suite.

(Corrected: this first said _save() called it twice. Counted against the code, it does not — the two-call site is _delete().)

The performance page has met a number like this before: it measured cacheflush() at 85 ms against the file cache and removed the calls from two test classes that did not need them. That was a directory scan and is unrelated to the keyspace traversal described here.

Not fixed here, on purpose — and fixed later the same day, once the measurement had been redone properly. A Redis set per category holding its own keys, so a flush is SMEMBERS plus DEL and costs the size of the category rather than the size of the database. See Clearing one cache category cost the whole database.

Worth knowing before picking it up: this only began costing anything when the SQL cache started working at all. Cache::getInstance()'s method default went from 'memcached' — a store nobody configured, so every call was a silent no-op — to one that resolves to the configured store. A consuming project's suite met the same change from the other side, as four tests that suddenly served stale rows.

And the answer to the question that started this

No, the last few days did not undo the performance work — beyond the two regressions above, which are fixed.

Of 290.6 s of measured test time, files added or touched in those days account for 26.9 s (9.2%) across 424 tests. The ≥ 1000 ms band grew from 19 tests to 68, but the classes in it were almost entirely older; the two largest were the two fixed above.

The --no-coverage run is 5:14 for 10,570 tests against a documented 3:44 for 9,750. Most of that is the suite having grown, and the honest way to read it is the per-test figures rather than the wall clock — which is why the page records them, and why it says to compare ranges rather than single measurements.

A filing against a patched vendor/, and the three real bugs inside it

Three findings arrived from a consuming application. Two of them described code that has never existed in this repository — it had been patched into that project's own vendor/mrpc/pramnosframework/ and read back as though it were upstream. That patch also contained three things that are real, and that nobody had filed.

The two that were not framework bugs

Both quoted Pramnos\Application\Controller::getModel():

  • a leftover fwrite(STDERR, "CANDIDATE: …") firing five times per model, per request, behind no flag;
  • a candidate list containing \App\Models\, \Admin\Models\ and \Edgeapi\Models\ — one application's namespaces hard-coded into a general framework.

The second reads as a serious design complaint, and it would be. Measured against the repository, at the exact reference composer.lock pins:

framework at 6a0d1a63  →  no fwrite, no application namespaces
vendored copy          →  both present

getModel() here derives the class from applicationInfo['namespace'] and has done throughout its history. The debug line and the namespace list appear in no commit. They were added to the vendored directory directly — most likely while debugging a migration — and a later reading of that file mistook it for upstream.

Worth saying plainly because it is a trap anyone can fall into, and the filing's own rules already guard against it: "run composer update and confirm the finding still exists in the latest dev version." A vendor/ that has been edited answers that check with the edit.

Three files in that vendored copy differ from the framework: Application/Controller.php, Http/Request.php and Routing/Route.php. All three are one composer update away from disappearing, which is the more urgent half of this: if anything in that project now depends on the patched getModel(), it breaks silently on the next update.

The three real bugs the patch was hiding

The Request.php half of that patch was not application-specific at all. It was fixing framework bugs — quietly, in a directory that gets overwritten.

The subdirectory strip assumed PHP_SELF is a web path

Request::__construct() cut strlen(dirname($_SERVER['PHP_SELF'])) characters off the front of the URI, unconditionally, to support an application served from a subdirectory.

PHP_SELF is not always a web path. Under the CLI — a console command, a daemon, a test runner — it is the script's filesystem path. Under PHPUnit that is …/vendor/bin/phpunit, whose dirname is 23 characters, so every URI lost its first 23. A relative PHP_SELF gives a dirname of . and eats one character.

This repository's own routing tests worked around it. They pin PHP_SELF to /index.php before constructing a Request, with a comment explaining that the constructor would otherwise truncate every URI in the file. So the same workaround had been written twice, in two repositories, by two people, and neither called it a bug. Those comments now describe a web request rather than a workaround.

The rule is now the one the intent implies: strip the directory only when the request actually starts with it. That also stops /myapplication/stations being mistaken for a /myapp subdirectory — a strlen()-based strip cuts on a partial name match.

calcParams() emptied $_GET and rebuilt it

Right for the keys it produces, wrong for every other one. Anything a front controller, a middleware, a rewrite rule or a test had put in $_GET was discarded, silently, with no way for the caller to know.

It keeps what was there now, and the query string still wins on a key it defines — which is what the original assignment did for every key it produced. r is still dropped, because that is the front controller's own routing parameter and never belonged to the application.

getInstance() could not be reset

The shared Request lived in a function-static, which nothing outside the method can reach. A process could therefore only ever have one request — so a suite that exercises routing, controllers or input could not start a second, and every caller going through getInstance() kept whichever one was built first.

Request::resetInstance() clears the instance and the derived statics: leaving $requestUri behind would hand the next request the previous one's address, which is the failure the reset exists to prevent, arriving one step later.

The third finding: modernizr, and why it is not coming back

The legacy pramnos_document_html carried public $modernizr = true; and injected <script src="…media/js/modernizr.min.js"> into every page. The modern Html document does not. The filing asked for either the feature back or a written statement that the removal was deliberate.

It was deliberate, for two reasons now written into the Document Output Guide:

  • the framework does not ship that file, so an unconditional injection would be a 404 on every page of every scaffolded project;
  • a page's assets are the application's decision, which is what the asset registry is for. A default that cannot be seen in the calling code is a default nobody knows to turn off.

Html::render() does inject one thing — the two-line inline script that replaces class="no-js" with js, inline because a round trip to decide whether JavaScript exists would arrive after the page had been painted. The guide now says so, and gives the addHeadContent() snippet for a theme that needs the rest of modernizr's feature classes. That snippet is the filing project's own workaround: it works identically on the legacy document and the modern one, so it is safe to add before migrating, and it needs no framework change.

The slash that only broke the routes with placeholders

A consuming application filed one line: 2acdf67 still has '/' . $uri without ltrim. It was right, the line was Routing\Route::matches(), and the cause turned out to be one level above it.

What broke

matches() hands the URI to Symfony's compiled pattern with a slash prefixed:

preg_match($this->getCompiledRoute()->getRegex(), '/' . $uri, $this->parameters);

Give it a URI that already starts with a slash and that is //stations/7. The compiled pattern is anchored, so it misses — every route with a placeholder, while every static route keeps working, because those are answered by a == comparison a few lines above and never reach the pattern.

That asymmetry is why it lasted. The routes anybody writes first, and tests first, are the ones that were fine.

Where the slash came from

Request trims the URI it reads from the environment — trim($_SERVER['REQUEST_URI'], '/'), and the subdirectory branch trims too. Request::create(), the factory a test or a console caller uses, did not:

self::$requestUri = $uri;          // before
self::$requestUri = trim($uri, '/');  // now

So getRequestUri() answered stations/7 for a real request and /stations/7 for a created one. Two ways of building a Request disagreeing about what the request was for — and every consumer of that value inherited it, not only routing. All 114 call sites in this repository pass a leading slash, because that is how a URL is written; none of them wanted it preserved.

Both halves fixed

The factory now produces the constructor's shape, and the match is defensive like its two siblings — Routing\OpenApiGenerator and Router::add() have written '/' . ltrim($uri, '/') all along. Fixing only the symptom would have left the next caller of getRequestUri() to find the same discrepancy again.

The test file keeps them apart on purpose: one test pins the factory's output shape, a second pins placeholder matching. Either fix alone turns the second green, so without the first the factory could silently regress.

Thirteen tests, covering the reported case, both placeholder shapes through the router, the static route that was never affected, and two URIs that must still not match — a fix that made the pattern looser would pass everything else here.

906 routing, HTTP and middleware tests pass unchanged.

A page cache, and the two things its spec could not know

A consuming application arrived with a 420-line specification for a full-page cache — written to replace about 140 lines of inline if in its own front controller, with the requirements derived from the seven bugs that code had actually produced rather than from imagination. Its inventory of what the framework already provided was checked claim by claim and was accurate in all nine.

Two things in it could not have been right, and both were about the framework's own internals rather than about page caching.

Added — Pramnos\Cache\Page\PageCache

Three files: the engine, Http\Middleware\PageCacheMiddleware, and a pagecache:purge console command. The Page Cache Guide is the reference; what follows is what is worth knowing that is not simply "it caches pages".

Every default is the one that caches less. enabled is false, statuses is [200] alone, a response carrying Set-Cookie is refused outright, and nothing is served to a request presenting an authentication cookie or header. A project that adds the middleware and writes no configuration gets a working site, not a randomly shared one. The failure mode of a page cache is somebody else's account page, and it is silent.

The session is never consulted, which is what lets serveEarly() answer before the application boots — every rule reads the request and nothing else. The cost is stated plainly in the guide: an application keeping logged-in state only in $_SESSION has no cookie for the decision to see and must set a marker.

Normalisation is where a page cache is won. Tracking parameters are dropped and what remains is sorted, so ?a=1&b=2 and ?b=2&a=1 are one entry and every campaign link is not a permanent miss. The implementation this replaces keyed on the raw query string; advertising traffic — the traffic a page cache exists for — had a 0% hit rate, and anyone could fill the store by appending junk.

Tags, because otherwise the only invalidation is the clock. "The correction appears within an hour" is why full-page caches get switched off after the first urgent edit.

Fixed in the spec — the stampede lock was not a lock

§6.5 asked for FlatCache::increment() as the lock behind stale-while-revalidate, with 'store' => 'file' as the default store.

The framework already says why that cannot work, in Cache::supportsAtomicCounter():

Only the adapters backed by a server that implements an atomic increment can — Redis and Memcached. The File and Array adapters cannot… Probing for the method would report the File adapter as atomic, which is precisely the "looks like it works and does not" answer this method exists to prevent.

On the file store increment() is a load followed by a save. Under concurrency every caller reads the same value and every caller believes it took the lock — at exactly the moment a stampede happens, which is the only moment the lock is for.

So the lock asks the store instead of assuming: swap() (Redis GETSET) where the counter is atomic, and a mkdir() lock where it is not — the same primitive this repository's own test runner uses, for the same stated reason. Both branches are tested for the property that matters: five arrivals inside the stale window, exactly one render.

Fixed in the spec — the purge would have inherited a 268 ms traversal

§6.7's purgeUrl()/purgeTag() were to be built on the category machinery. Measured yesterday: clearing one category is 268 ms, because Redis SCAN walks the whole keyspace whatever the MATCH says — MATCH filters what comes back, not what is traversed. A per-record purge would cost the size of the database.

The page cache therefore keeps its own indexes — a hash per tag and per URL, holding their entry keys — over FlatCache, which writes keys verbatim rather than through the category-mangling Cache. A purge reads that tag's members and deletes them: the size of the tag.

This does not fix the underlying category flush, which is still written up as its own piece of work. It means the page cache does not depend on it.

Also built — the static-file writer, with its measurement attached

§10 was marked second priority and explicitly not a v1 blocker; it is included. 'writer' => 'static' writes index.html and index.html.gz so a rewrite rule serves them without PHP starting.

The spec's own §10.3 measured the benefit at ~1.3 ms/hit for that application and concluded it was worth little there — which is right, and the honest reason is in the guide: the gain scales with the weight of the bootstrap being skipped, not with the web server. An application already using serveEarly() saves a millisecond or two; one that boots fully before consulting the cache saves tens.

Three sharp edges are handled rather than documented away: a URL with a query string is never written as a file (a rewrite rule cannot apply ignoreQuery, so it would serve the clean page for ?page=2); files are written to a temporary name and renamed; and purges remove the static twin, without which the rewrite keeps serving the file the purge reported having removed.

A URL that decodes to contain .. writes nothing — a page cache that turns request paths into filesystem paths is a directory traversal waiting to happen, and the check belongs in the writer rather than in whatever calls it.

Tests

105, over four files. The weight is deliberately on the refusals — the cases where the right answer is not to cache — because those fail silently and stay failed: the Set-Cookie response, five spellings of an authentication cookie, six HTTP methods, five statuses, the private marker, the header whitelist, and three traversing URLs.

Coverage: PageCache 95.8%, PageCachePurge 95.2%, PageCacheMiddleware 100%. What remains uncovered is the Memcached and Memcache adapter construction — neither extension is loaded in the container — and two defined() branches.

Clearing one cache category cost the whole database

RedisAdapter::clear($category) deleted by pattern. That reads as a narrow operation and is not one, and it was on the path of every model write.

Fixing it meant first retracting the number that motivated it.

The retraction

Yesterday's post and the performance page both said a category clear cost 268 ms, measured in the suite. Asked to implement the repair, the first step was to reproduce the cost, and it does not reproduce.

The suite's cache resolves to FileAdapter — the test fixtures configure no cache method — and a category clear there is 0.05 ms. So the 268 ms was not measured where it was said to be measured, and the claim that cacheflush() explains the 1.0–1.9 s per test in MessagingModelsPostgreSQLTest is withdrawn. That cost is currently unexplained.

Worth saying at this length because the number had already been repeated into two documents and was about to justify a third change. A measurement that cannot be re-run is not a measurement.

What is real, and measurable

The mechanism. SCAN with a MATCH traverses the whole keyspaceMATCH filters what comes back, not what is walked. So the cost of clearing one category was a function of everything else in the Redis database: other categories, sessions, rate limiters, another application entirely.

Measured directly, category held at 40 keys:

keyspace SCAN + MATCH SMEMBERS + DEL
1,000 0.6 ms 0.29 ms
10,000 1.2 ms 0.70 ms
100,000 15.8 ms 0.27 ms 58×
500,000 128.7 ms 0.85 ms 151×

One column is linear in the size of the database. The other is flat.

At a thousand keys this was not worth fixing. That is the honest reading of the top row, and it is why the number mattered: the change is justified by the slope, not by any single measurement.

Model clears on every write — once per save, twice per delete, counted against the code — so the left column was the price of a write in production.

The fix

A Redis set per category, holding that category's keys. A clear is SMEMBERS plus DEL.

Three details carry the correctness, and each has a test that fails without it.

The set outlives its newest member. Every save pushes its expiry to an hour past that entry's own TTL. That bounds its growth in a category written constantly and never cleared, and it guarantees the set is never the first thing to expire — a set that went while its members lived would leave them unclearable, which is the same stale-for-ever failure the change exists to prevent. An entry saved with no expiry makes the set permanent, because there is then a member that will never leave on its own.

An installation that predates the index still gets its old keys removed. Keys written before the set existed are in no set, and among them are entries saved with no expiry that would otherwise sit there for ever. A per-category marker decides: no marker means scan once, the old way, then write the marker. So the crossover happens exactly once per category, and never again.

Deciding that by "is the set empty?" would have been wrong twice over — an empty set is also what an idle category looks like, so every clear of one would pay the scan again, and that is the common case.

A key written into a category's namespace by something other than the adapter is no longer swept. That is the real cost of not searching. It is pinned by a test rather than left to be discovered, and the crossover scan is what makes it safe on upgrade.

Tests

18 integration tests against live Redis on database 9, plus one unit test for a sMembers() that returns false instead of an array — which some phpredis versions do, and a connection that has just dropped does. Passing that to array_chunk() would throw a TypeError out of a cache invalidation, turning a degraded cache into a failed request.

The tests pin the mechanism structurally rather than by timing — a stopwatch assertion in CI is a flake waiting to happen. The proof that the clear reads the index is that a key matching the old pattern, written behind the adapter's back, survives: under the pattern scan it would have gone.

Coverage on the new and changed code: 100%. 754 cache and Redis tests pass.

Not changed

The Memcached, Memcache, File and Array adapters. Memcached cannot enumerate keys at all and the others do not scan, so none of them had this cost; giving them an index they cannot use atomically would add a failure mode to buy nothing.

Two bodies, one URL, and no Vary

The page cache shipped this morning. A consuming application read it and filed three things about one method, all correct, within hours.

Fixed — Vary: Accept-Encoding was never sent

With gzip on, responseFrom() chose between the stored plain body and the stored compressed one by reading Accept-Encoding off the request. Two different bodies under one URL, and nothing telling anyone.

Every shared cache in front of the application — a CDN, a corporate proxy, a reverse proxy — assumes one response per URL unless a Vary says otherwise. So it could store the compressed variant and hand it to a client that never asked for compression, which receives binary rubbish. Or the reverse. It is the classic "the page is broken, but only for some users" report, and it does not reproduce on a developer's machine, because a developer's machine has no proxy in front of it.

Vary: Accept-Encoding is now sent on both branches. Only tagging the compressed response would fix half of it: a shared cache that happened to see the plain copy first has the identical problem in reverse, and it is the same URL either way.

An application's own Vary is merged rather than overwritten — dropping a page's Accept-Language to add ours would break the caching of exactly the pages that were careful about it — and with gzip off nothing is added at all, since there is then one body per URL and a needless Vary costs hit rate in every cache downstream.

Why the whitelist did not cover it. vary is on headerWhitelist, so a Vary the application sends is preserved. But compression is the cache's decision, not the application's, so the application has no reason to know it must declare anything.

Why the tests did not catch it, which is the part worth keeping. There were two, one per branch, and each asserted Content-Encoding on the response it produced. Both passed. A test that checks the header it expects cannot fail on the header nobody thought of — and 105 green tests read as thorough coverage of exactly this method. The replacement asserts Vary across four Accept-Encoding shapes including the absent one.

Fixed — the 304 carried no ETag

Response::make('', 304) and then the debug headers. RFC 7232 §4.1 says a 304 includes the validator; without it a client cannot tell which of its stored copies has just been confirmed, and some re-download on the next cycle — losing the round trip the 304 exists to save.

Fixed — If-None-Match was compared, not parsed

trim($header) === $etag. That misses three shapes clients actually send:

sent before now
"abc" 304 304
W/"abc" 200 304
"other", "abc" 200 304
* 200 304

None of these was a correctness bug — the answer fell back to a full 200 — which is why it would have gone unnoticed indefinitely while quietly throwing away the saving the ETag was added for. A weak validator is the same entity for this purpose: the cache serves whole stored bytes, so there is no strong comparison to fail.

The report itself

Worth recording how it was written, because it made the fix cheap: it quoted the code, named the method, stated what it checked ("the string Vary does not appear anywhere in PageCache.php, and Response::send() does not add it"), explained why headerWhitelist looked like a defence and was not, identified the two shipped tests that covered both branches and still missed it, and proposed the fix including the half-fix to avoid. It also said plainly that it was not blocking, and gave the workaround — 'gzip' => false, since Apache's mod_deflate already compresses and emits the correct Vary.

That last point is now in the guide as a recommendation rather than a workaround: if the web server already compresses, let it, and avoid storing two copies of every page.

A model that says what it changed

Two classes land today. Neither does anything visible on its own, which is the point: they are the seam everything else in this line of work hangs off.

Added

  • Pramnos\Event\ModelChange — one change to one record, as a readonly value object: what entity, which key, created/updated/deleted, the record, the diff, who did it and from where.
  • Pramnos\Event\ChangeFeed — delivers those under a single event name, model.changed, and holds them while a database transaction is open.
Event::listen(ChangeFeed::EVENT, function (ModelChange $change) {
    if ($change->entity === 'wcm-device' && $change->has('status')) {
        // …
    }
});

Nothing emits into it yet. The Model hooks that will are the next commit; this one is the contract they write against.

Why the transaction buffer is here from the start

It would have been easy to deliver every change the moment it happened and add buffering later, when something complained. Nothing would have complained loudly.

A broadcast published for a change that then rolled back costs one wasted refetch that returns the old data — it heals itself, silently, and nobody files a bug. But the same feed is meant to drive an audit log, and a changelog row recording a change that did not happen is not self-healing. It is a record that is wrong, indistinguishable from the records that are right, discovered — if ever — long after the transaction that produced it is unreconstructable.

So the buffer exists for the listener that writes things down, not for the one that publishes them. Its test is the one that matters:

FakeTransactionChangeFeed::$open = true;
FakeTransactionChangeFeed::emit($change);
FakeTransactionChangeFeed::discard();
// nothing was delivered

Two limits, stated rather than papered over

Database::inTransaction() tracks a single flag, not a depth counter. Nest two transactions around model saves and the inner commit flushes while the outer is still open. A raw BEGIN through query() is not tracked either — its own docblock has always said so.

Neither is worked around in the feed. Fixing them properly means a depth counter inside Database, which is a change to shared machinery on behalf of a feature that does not need it yet. They are in the guide instead, next to the buffer they affect, with what to do about it.

One event name, not three

model.changed, and listeners switch on $change->entity and $change->op.

The alternative — also firing model.wcm-device.updated — reads better at the call site and costs a second registration that has to be kept in step with the first. One of the two gets forgotten, and the listener that was registered against the forgotten name receives nothing, with no error anywhere.

Documentation

A commit that says so

commitTransaction() and rollbackTransaction() now fire an event. Two lines of production code, and the reason they are worth a post is the asymmetry between them.

Added

Event Name When
ChangeFeed::EVENT_COMMITTED database.transaction.committed after a successful COMMIT
ChangeFeed::EVENT_ROLLED_BACK database.transaction.rolledback after ROLLBACK, whether or not it succeeded
Event::listen(ChangeFeed::EVENT_COMMITTED,   fn() => /* rows are durable */);
Event::listen(ChangeFeed::EVENT_ROLLED_BACK, fn() => /* drop what you held */);

Why one is conditional and the other is not

The commit event fires only when the COMMIT succeeded. A failed commit leaves rows that may or may not be there, and releasing listeners onto data in that state is worse than releasing them onto nothing.

The rollback event fires either way, and that looks inconsistent until you ask what a listener does with it. It drops work it was holding. "I could not undo that" is not a reason to go ahead and announce the change — a listener that has already written an audit row cannot take it back, so the safe reading of a failed rollback is still do not announce it. Being wrong in the direction of silence costs a missed notification. Being wrong the other way puts a permanent record of something that may never have happened.

Named for what happened, not for who listens

They are database.transaction.* rather than changefeed.* because the change feed is the first consumer, not the only plausible one. Cache invalidation wants the same seam. So does an outbox. Naming an event after its current subscriber is how you end up with a second, near-identical event the day a second subscriber appears.

What the integration tests pin

Ten tests across PostgreSQL and MySQL, and two of them are about ordering rather than firing:

  • by the time a listener runs, inTransaction() already answers false — otherwise a listener that re-enters the feed would buffer into a transaction that has ended and hold its work until a commit that never comes;
  • the rows are actually durable when the committed event fires, asserted by counting them from inside the listener.

An event that fired a moment too early would pass a naive test and fail in exactly the case it exists for.

Documentation

A save that announces itself

Model::_save() and _delete() can now emit on the change feed. One property turns it on; every existing model stays silent.

Added

class Device extends \Pramnos\Application\Model
{
    protected $emitChanges = true;
    protected $changeEntity = 'wcm-device';
    protected $changeIgnoreFields = ['viewcache', 'stats', 'alerts'];
    protected $changeSignificantFields = ['status', 'customerid', 'eui'];
}

Also changeChannels() for multi-tenant channel naming, withoutChangeEmission() for operations whose physical shape is not their meaning, and $broadcastFields — which is null by default and means a broadcast carries identifiers only.

Fixed

  • OrmModel's soft delete announced an update. It performs its work through parent::_save(), so the base class would have described a delete as an UPDATE and a subscriber would have kept showing a row the application considers gone. The write is now silenced and the truthful deleted emitted in its place.

The test that would be the one to keep

public function testAModelThatHasNotOptedInEmitsNothing(): void
{
    $model = $this->model();

    $model->emit(ModelChange::CREATED);
    $model->emit(ModelChange::UPDATED, ['status' => ['old' => 'a', 'new' => 'b']]);
    $model->emit(ModelChange::DELETED);

    $this->assertSame([], $this->received);
}

Every model in every application that upgrades is that model. Asserted for all three operations rather than one, because the guard is a single early return and a refactor could move it below any of them.

The exclusion list caught all six new properties

Model::getData() filters internals out of every payload using INTERNAL_PROPERTIES, a hand-maintained list — and the test that guards it derives its side from the class by reflection rather than trusting the list. Adding six properties without listing them failed immediately:

+    0 => 'Pramnos\Application\Model::$emitChanges',
+    1 => 'Pramnos\Application\Model::$changeEntity',
+    2 => 'Pramnos\Application\Model::$broadcastFields',
...

$changeEntity is a plain string, so the old type filter would have waved it through into every API response of every model that opted in. That test was written after the list turned out to be missing two entries the last time; it earned its keep again.

A second, subtler consequence showed up next to it. Three tests assert getData() is byte-identical to the implementation it replaced, by running a literal copy of the old code beside it. That copy is 2018 logic, and it was now being run over a 2026 object — so it reported six new internal properties as differences. Nothing was broken: the properties never existed in any release, so no payload changed for anybody. The reproduction just needed telling that machinery added since is not part of what backwards compatibility covers.

Where it fires, and where it deliberately does not

_save() emits once, at the very end, after every path that could still return early — a save with nothing to change, an update whose statement threw. A change that did not reach the database is never announced.

_delete() emits with the key that was passed in, not the one the model holds, because _delete($primaryKey) does not load the row. It still does not: that would be a query on every delete to populate a payload the default mode does not send. Code needing full data on delete loads the model first, which an application doing so already does.

Documentation

  • Model Change Feed Guide — turning it on, payload modes, and the multi-tenant channel warning, which is stated where the feature is turned on rather than in a reference section.

A bypass that only stopped half of it

PageCache::bypass() meant "do not save this page". It did not mean "do not serve one", and the missing half is the dangerous one.

Fixed

  • PageCache::bypass() now stops a lookup as well as a store. The runtime flag moved into bypassCheck(), which both halves already consult.
  • whyBypassed() reports it, as runtime:<reason>. It previously returned null for a request the application had explicitly refused.

Added

  • skipWhileDebugging (default true) — a response is not stored while the debug toolbar is collecting.

How the bypass failed

store() asked two questions:

if ($this->bypassCheck($request) !== null || self::isBypassed()) {

lookup() asked one. self::isBypassed() appeared exactly once in the file.

Both halves read correctly in isolation, which is why this survived review and was caught by an HTTP test in a consuming application instead. That application calls bypass() whenever a session exists, so its signed-in visitors were served the anonymous cached page — logged-out header on a logged-in page. Its front controller now carries a hand-written isBypassed() check before lookup() with a comment pointing at the framework; that check can go.

The fix is in bypassCheck() rather than at the top of lookup(), so a third call site cannot forget it and so whyBypassed() gets it for free. A diagnostic that answers "this request is cacheable" about a request the application has refused sends whoever is debugging to the configuration, which is the one place the answer is not.

The toolbar was being cached with the page

Application::render() injects the debug toolbar into the string it returns. A front controller then wraps that string in a Response and hands it to store() — and nothing in between could notice. privateMarkers is empty by default. A toolbar sets no cookie. So the guard that catches per-visitor responses did not catch this one.

What would be stored is one developer's SQL with its bound values, their timings and the files that ran, served to everyone who asks for that page next.

APP_DEBUG is meant to be off in production, which bounds it and does not close it: a staging environment with real data and the page cache on is an ordinary thing to have, and there the failure is silent. The guard uses the same condition injectInto() uses to decide whether to inject at all, so "there is a toolbar in this body" and "refuse to store this body" cannot drift apart.

There is an escape hatch, off by default, because the alternative to a switch is somebody editing the guard out locally and pushing it:

'skipWhileDebugging' => false,   // only where the stored pages reach nobody else

While we were there

A hit returns a Response before the application runs, and DebugBarMiddleware only decorates string responses — so a cache hit carries no toolbar at all. That is correct, and an easy thing to misread as "debug is broken". Said out loud in the guide, next to X-Pramnos-Cache, which is what actually tells you a hit happened.

Documentation

  • Page Cache Guide — the store rules, a new section on the toolbar, and a rewritten "When a page is not being cached" that now opens with the one command that answers it most of the time:
curl -D- -o /dev/null -s https://example.test/directory | grep -i set-cookie

An application can now decline a session

Two things started per-visitor state on every request with no way to say no. Together they were what stopped the page cache — shipped the same day — from ever storing a page.

Added

// app/app.php
'session'          => 'lazy',   // no session for a visitor who has none
'session_tracking' => false,    // no tracking cookies, no sessions upsert

Both default to the behaviour that shipped. Neither is something a minor release gets to change underneath an application.

Why the page cache stored nothing

Application::init() started a session unconditionally, so every response carried Set-Cookie: PHPSESSID — including a page render for an anonymous visitor who never reads or writes a thing. PageCache refuses to store a response that sets a cookie, correctly, because such a response is per-visitor in its body too.

So the two features could not both be used as shipped, and the reason was two lines the application did not write. Reported from a consuming application that had removed every other cookie it set: one Set-Cookie left on an anonymous page, and it was this one.

curl -D- -o /dev/null -s https://example.test/ | grep -i set-cookie

"Lazy" had to mean the narrower thing

The obvious implementation — never start a session, let ensureStarted() do it on first use — does not survive contact with the codebase. Around two hundred places in the framework read $_SESSION directly, Session::staticIsLogged() among them. Under a never-start rule it would report every signed-in visitor as anonymous until something happened to call a token helper. Nobody could turn that mode on.

So lazy means do not create a session for a visitor who has none. A request carrying a session cookie starts one at exactly the point it always did.

Request Eager (default) Lazy
Anonymous, no cookie session started, cookie sent no session, cacheable
Carrying a session cookie session started session started

Any request that has state gets it; only the ones that would have created state for no reason do not.

The other half: fifty-one writes

$_SESSION is written in 51 places across 14 framework files, and PHP will happily let you write to it with no session started — the value goes into a plain array and is gone at the end of the request, with no error and no warning.

The ones reachable on a request that may have no session now call ensureStarted() first: signing in, the pending two-factor step, passkey challenges, validation errors and old input, flash messages and errors, and ?lang=. Auth::login() is the one that would have hurt most — it sits directly after regenerateId(), which returns false without an active session, so under a naive lazy mode both the fixation defence and the four writes below it would have quietly done nothing and nobody would have been able to sign in.

This is why the mode is opt-in. An application with its own $_SESSION writes has to do the same, and the guide says so where the key is introduced.

Two fixed on the way past

  • FormRequest::failWith() called bare session_start(), which ignores the cookie parameters Session::start() sets — secure, httponly, samesite. A validation failure was the one request that got a laxer session cookie than every other one.
  • Base::addError() and addMessage() were guarded by isset($_SESSION), so they silently dropped the message when there was no session. That was almost never, because init() always started one; under lazy mode it would have become common. A flash message nobody sees is worse than a cookie on a page that was about to redirect anyway.

bootSessionTracking() ran SessionTrackingMiddleware for any application that did not name it in middleware. So the supported way to decline a feature was to declare it, and then arrange not to run it — in two files, each carrying a comment explaining the other, because either half alone reads as a mistake.

It cost exactly what that shape costs. One application's app.php carried the comment "NO SessionTrackingMiddleware … session tracking is deliberately NOT wired" and it had been running the whole time: two cookies and an upsert into sessions on every request, crawler hits included. They had a passing test named for the claim while the behaviour was its opposite.

'session_tracking' => false is checked before the two inference rules, so an explicit answer is never overruled by a guess about one. It accepts the spellings a config file actually contains — false, 0, '0', 'false', 'no', 'off', '' — because 'false' from an env-driven config silently enabling the thing it names would be the same bug again in a new place.

Documentation

  • Framework Guide — two new sections, "Declining the automatic session" and "Declining session tracking", including what a $_SESSION write without ensureStarted() costs.
  • Page Cache Guide — the diagnosis section now opens with the cookie check and points here.