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, withuse_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.languageis 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.jsspeaks the framework's own API contract — anapiKeyheader, anaccessTokenheader,/account/login— which an attribute-routed app authenticating withAuthorization: Bearershares 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:create:api-client's generated module composes its own query and callsapi.getwith 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¶
- Console Guide
—
create:screen,create:component, the control-per-type table, and the shared components' contracts. - Application Styles Guide — what
the SPA target of
create:crudnow actually produces. - Internationalization Guide —
translating a front end from the same catalogue, and
getLanguages().
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$effectthat 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. Socreate:component StatusBadgewould have writtenStatusbadges.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$freshflag — 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 Guide
—
getColumns()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; unsignedis 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¶
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_DOWNLOADis the body alone, so aheadersOnly()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.
nullmeans nobody measured, not zero. A faked response has no transfer to report, and0would 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 thatcacheflush()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:
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:
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:
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 keyspace — MATCH
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 | 2× |
| 10,000 | 1.2 ms | 0.70 ms | 2× |
| 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¶
- New: Model Change Feed Guide, wired into the nav.
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 answersfalse— 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¶
- Database API Guide — new "Transactions announce themselves" section under Transaction Management, including the one-flag-not-a-depth-counter warning.
- Model Change Feed Guide — the consumer.
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 throughparent::_save(), so the base class would have described a delete as anUPDATEand a subscriber would have kept showing a row the application considers gone. The write is now silenced and the truthfuldeletedemitted 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 intobypassCheck(), which both halves already consult.whyBypassed()reports it, asruntime:<reason>. It previously returnednullfor a request the application had explicitly refused.
Added¶
skipWhileDebugging(defaulttrue) — a response is not stored while the debug toolbar is collecting.
How the bypass failed¶
store() asked two questions:
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:
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:
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.
"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 baresession_start(), which ignores the cookie parametersSession::start()sets —secure,httponly,samesite. A validation failure was the one request that got a laxer session cookie than every other one.Base::addError()andaddMessage()were guarded byisset($_SESSION), so they silently dropped the message when there was no session. That was almost never, becauseinit()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.
Omission was being read as consent¶
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
$_SESSIONwrite withoutensureStarted()costs. - Page Cache Guide — the diagnosis section now opens with the cookie check and points here.