16 August 2026¶
15 changes:
- Wrote 1 path(s), and every word of it was true
- The JSON renderer decided every response was fine
- A failed list query killed the request
- The controller a model needs costs 1.5 microseconds
- The one place a shared layout could not be
- A comparison table is a claim too
- The redirect the guide promised
- The cache category was accepted and discarded
- A marker nothing flipped
- The HTML document could not say what page it was
- getData() and the columns it never returned
- An alarm that stays rare
- A column that described data nobody wrote
- Fourteen tabs do not fit
- Two things that did not complain
Wrote 1 path(s), and every word of it was true¶
api:docs scanned src/Controllers and wrote into www/. For an application that
keeps its API in src/Api/Controllers and is served from public/, both are wrong,
neither appeared in the output, and the command reported success.
One endpoint of seventy-two¶
A consumer ran it against an application serving 72 endpoints:
Nothing there is false. It did write one path, it did write it to that file. What it
did not say is where it looked — src/Controllers, which in that application holds
the single MVC controller, added the week before beside 51 attribute-routed API
controllers in src/Api/Controllers.
The scan is the dangerous half, and it is worth being precise about why. A file written to the wrong directory is noticed the first time somebody opens it: the path is right there in the output, and the URL 404s. A document describing 1 endpoint of 72 is published and believed — it is indistinguishable from an application that genuinely has one endpoint. There is nothing to notice.
Four defaults, and a fifth thing nobody had asked about¶
The filing named four fixes. All four landed:
- The success line names what was scanned, not only where it wrote:
Scanned src/Api/Controllers (namespace App\Api\Controllers)
Wrote 72 path(s), 96 operation(s) to /srv/app/public/api/openapi.json
-
A thin result says so. When a sibling directory holds more operations than the one scanned, the command names it and the counts. Nothing is switched under you — a directory swapped silently would be a worse surprise than a thin document — and the check is skipped entirely when
--controllerswas passed, because naming the directory is a decision, not a guess to correct. -
The default looks for the API first:
src/Api/Controllers, thensrc/Controllers. An application with only the second is unaffected. -
The output follows the document root — whichever of
www,public,html,webholds anindex.php. Hardcodingwww/stopped being defensible the momentpramnos initgrew--web-root: a project scaffolded with--web-root=publichad this command create awww/beside it, served by nothing.
The fifth was found while fixing the others, and it is why the documented escape
hatch did not work either. detectNamespace() appended a fixed \Controllers to
the application namespace regardless of which directory it had been told to scan. So
the command's own usage block — --controllers=src/Api/Controllers, with no
--namespace — looked for App\Controllers\* inside src/Api/Controllers, found
nothing, and exited 0. Somebody following the documented workaround would have gone
from a document with one endpoint to a document with none, and got the same
reassuring Wrote line either way.
The namespace follows the directory now: application namespace plus the path after
src/. src/Controllers still gives App\Controllers, so nothing that worked
changes.
The shape¶
The command contained the evidence against itself the whole time. Its usage block
offered --controllers=src/Api/Controllers as the example, which is the layout the
default did not look in — and the same example was broken by the namespace bug, so
neither half could have been run recently by anybody.
That is the second time this week a defect has been sitting inside its own documentation: the Document guide's SEO section named three methods that never existed, and this usage block described a workaround that could not work. Prose beside code is not checked by anything, and both were found by running it rather than reading it.
Fixed¶
api:docsprints the directory and namespace it scanned.- It warns, naming counts and the alternative, when a sibling controllers directory holds more operations than the one it scanned.
--controllersdefaults to the first ofsrc/Api/Controllers,src/Controllersthat exists.--outputdefaults to<document root>/api/openapi.json, detected rather than assumed to bewww/.--namespaceis derived from the controllers directory instead of a fixed\Controllerssuffix — the documented--controllers=…example produces a document now.
Documentation¶
- Routing guide — what the command looks at, what it writes, and what it prints; the example no longer passes options it now works out.
- Application Styles guide — check the
Scanned …line against where your API lives before publishing the result.
The JSON renderer decided every response was fine¶
Json::render() opened with header('HTTP/1.1 200 OK'). Every JSON error — 404, 403,
500 — was therefore served as 200 OK, and a client checking response.ok saw every
failure as a success carrying strange data.
Found by a warning, not by a report¶
The previous commit added an http_response_code() call to showError(). The suite
came back green with one warning:
Which is PHP saying, politely, that something upstream had already written a status
line by hand. Three places had: Json::render(), Rss::render() and
CorsMiddleware's preflight branch.
The CORS one is harmless in effect and wrong in form. The other two are not harmless.
Two failures from one line¶
It stamps 200 over a status the caller already set. An API that decided this
request is a 404 renders its body through Json::render(), and the body-rendering step
overwrites the decision. For an SPA using fetch, response.ok is true for every
error the API returns.
It pins the status. Once a status line has been written by hand, PHP ignores every
subsequent http_response_code(). So a middleware that runs after rendering, or an
error handler further out, cannot correct it — and PHP reports that only as a warning,
which in production is a line in a log nobody reads.
200 is PHP's default. The line was a no-op in the one case where it was correct.
What the tests could and could not do¶
The behavioural test — set 404, render, assert still 404 — fails when the fix is reverted. That one works.
The obvious companion — render, then set a code, then check it applied — cannot be written. PHP's "a status line was sent by hand" flag is global to the process and there is no way to clear it, so the first test to trip it decides the answer for every test after it. Two such tests were written first, and both passed with the defect present, in whatever order PHPUnit happened to run them.
So that half is structural instead: no document type may contain header('HTTP/...').
It covers Rss — which the behavioural test never reached — and every renderer added
later, which is where a fix applied once goes missing.
That guard was then wrong in the way this ledger has been collecting all week. Its
first version pointed dirname(__DIR__, 5) at a directory outside the repository,
found no files, and passed. A guard that scans nothing reports success. It now
asserts it scanned something before asserting what it scanned is clean — the authority
for "there is anything to check" has to come from outside the loop doing the
checking.
Fixed¶
Json::render()andRss::render()no longer write a status line, so the status the caller set survives being rendered.CorsMiddlewareanswers preflight withhttp_response_code(204)instead ofheader('HTTP/1.1 204 No Content')— the hardcodedHTTP/1.1is also wrong on an HTTP/2 connection.
Documentation¶
- Document & Output guide — The document
does not decide the status code: what a renderer owns, what it does not, and why
header('HTTP/...')is never the right call.
A failed list query killed the request¶
Model::_getList() caught a query failure and then called showError(), which exits.
The two lines under it — record the error, return an empty list — are what the API's
own error envelope was written against, and they were unreachable on the one path that
needs them.
The dead lines¶
} catch (\Throwable $ex) {
Logger::logError(...);
if ($displayerroroutput == true) {
$this->controller->application->showError($ex->getMessage()); // ← exits
}
$this->sqlError = $ex->getMessage(); // never reached
return array(); // never reached
}
$displayerroroutput defaults to true, so those last two lines only ran for a caller
that had explicitly asked for silence. Everyone else got the process torn down.
For a page that is defensible — without the list there is nothing useful to render. For
an API it is not, and the framework had already written the alternative:
ApiListResponse::error() builds {"error": …, "data": [], "pagination": null}, reading
exactly the sqlError that the exit prevented from being set.
So the branch is now taken on what the client asked for. A request whose Accept names
JSON records the error and returns an empty list; a browser still gets the error page,
unchanged.
Application::clientWantsJson() became public for this. Model could have tested the
header itself, and that second copy is precisely the thing that drifts — one path
answering HTML because somebody improved the other one.
The reversal is the interesting part¶
With the fix reverted, the test does not fail. It dies:
That is the defect stated exactly: not a wrong answer, an absent one. A test written to
assert the returned value can only ever report Error, never Failure, because there
is no return.
It also showed something worth fixing on the way past. That payload carried
"title": "Maintenance Mode" — for a database fault. The parameter's default is
right for the branch it was named after and misleading everywhere else: an API client
reporting "Maintenance Mode" sends whoever reads it to go and check the deploy. The
title is now carried only when it says something — an actual maintenance stop, or a
caller that passed one.
Fixed¶
- A failed
_getList()query no longer ends a request whose client asked for JSON; it setssqlErrorand returns an empty list, which is whatApiListResponse::error()needs. Application::clientWantsJson()is public, so there is one implementation of the question rather than one per caller.- The JSON error payload no longer says
"title": "Maintenance Mode"for faults that have nothing to do with maintenance.
Documentation¶
- API guide — When something fails on the server: what an API client is told when a list query fails, and when the framework stops the request outright.
The controller a model needs costs 1.5 microseconds¶
Model::__construct() requires a Controller. That reads as a hard dependency on the
MVC stack, and it has been quietly deciding architecture in projects that never
measured it.
Five references, two of them real¶
Inside Model, $this->controller appears five times:
| Line | What it does |
|---|---|
| 99 | the assignment |
| 240 | $this->controller->getModel($model) — sibling lookup |
| 859 | the error path |
| 725, 880 | passes itself to the next model being constructed |
Three of the five exist only to carry the thing onwards. And Orm\Relations\Relation
explained the dependency with a reason that is false:
We pass the parent's controller so the model can reach the database.
It does not. Model::__construct() calls Database::getInstance() on the line below.
A wrong reason in a comment is worse than no comment: it makes the dependency look load-bearing, so somebody trying to use a model from a queue worker reads it and concludes they need to fake a request.
So it was measured¶
new Controller()— 1.54 µs- the
Application::getInstance()behind it — 1.3 ms cold, 0.002 ms warm
The dependency costs nothing and looks like it costs a great deal. That gap is the whole finding: it is exactly the shape that gets designed around rather than checked.
ServiceController¶
use Pramnos\Application\ServiceController;
$post = new \App\Models\Post(ServiceController::shared());
$post->load($id);
No framework change was needed for this to work — new Controller() was always safe to
call. What was missing was a name, so that every application working it out
independently stops inventing its own and rediscovering that fact.
shared() because a service building several models wants one controller: models from
the same controller resolve each other through getModel(), and a fresh one re-runs a
reflection and a permissions normalisation for nothing. forget() for tests, because
the instance holds the Application current when it was built and would otherwise leak
into whichever class runs next.
It grants no permissions, and there is a test pinning that. Code outside a request has no user. A controller that quietly behaved as though it did would be a much worse thing to put in a framework than the inconvenience it removes.
What this does not settle¶
Whether a services-oriented application should introduce models is a separate
question with real arguments on both sides. This only removes one bad argument from it:
the Controller parameter is not a reason to decide either way, and it should never
have been read as one.
Added¶
Pramnos\Application\ServiceController— aControllerfor code with no MVC request behind it, withshared()andforget().
Fixed¶
Orm\Relations\Relation::newRelatedInstance()no longer documents a reason for the controller that was never true.
Documentation¶
- Application Styles guide — Using a Model outside an MVC request, with the measurements and the permissions caveat.
The one place a shared layout could not be¶
View::resolveTemplatePath() searched the view's own directory, ROOT/views and a
theme override. It never searched src/Views — the directory holding the view
directories, and the obvious place to put a layout shared between them.
Where it looked¶
For a view at src/Views/Home, $this->layout('layouts/main') looked in:
src/Views/Home/layouts/main.html.phpROOT/views/layouts/main.html.php- the theme's
views/layouts/main.html.php
Not src/Views/layouts/main.html.php, which is where a developer with four view
directories and one layout puts it. src/Views is now searched, appended last, so
nothing that resolved before resolves differently — a per-view override keeps its
priority over a shared file that has been sitting there all along.
The half that matters more¶
When a declared layout does not resolve, getTpl() renders the child alone. No
exception, no log, and a 200. The page comes back with no <head>, no navigation and
no stylesheet link, which presents as "the CSS did not load" — so the next hour goes
into asset paths and caching headers.
It is logged now:
Layout not found: layouts/absent (searched from /srv/app/src/Views/Home). The view was rendered without it.
A framework cannot know every place somebody will put a file. It can refuse to be silent about not finding one. Adding the directory fixes the case we know about; logging fixes the ones we do not.
The feature had no guide page¶
layout(), section(), endsection(), yield() and insert() appeared in exactly
one place in the documentation: 1.2-new-features.md, which is frozen. So the
resolution order could not be corrected there even if somebody had noticed it, and a
reader looking for how layouts work in the current framework had nowhere to land.
The Framework guide's Views and Templates section now carries them, with the search order as a numbered list and the missing-layout symptom named — because "the page looks unstyled" is what somebody will actually be searching for.
This is the third feature this week found documented only in a page that cannot be updated. The habit that catches it is cheap: when fixing something, grep the guides for the method name before writing the fix, not after.
And every page carried its own file path¶
Unrelated to layouts, in the same method:
Appended to every HTML view, unconditionally, in production as much as anywhere else. While building a page it is a convenience. On a public server-rendered page it tells anybody reading the source where the application's files live, and search engines index it with the rest of the markup.
Debug mode only now — asked of Application::isDebugMode() rather than by reading
DEVELOPMENT in the view, because that method also honours APP_DEBUG, and a second
copy of the decision would answer differently on the machines using the environment
variable. With no application to ask it answers false: a view rendered outside a
request is not a debugging session, and the safe answer about a disclosure is the quiet
one.
Fixed¶
View::resolveTemplatePath()searchesdirname($this->path)—src/Viewsfor a standard layout — after the existing locations.- A declared layout that cannot be resolved is logged instead of silently dropping the page's entire structure.
- The
View Rendered at/View Pathcomment is emitted in debug mode only.
Documentation¶
- Framework guide — Layouts and partials: the five search locations in order, and what a missing layout looks like from the browser.
A comparison table is a claim too¶
The Application Styles guide's opening table said the Services + API + SPA style has "View layer: none". An application in that style had already added a controller returning HTML, which the framework has always supported.
What the row was doing¶
The table is the first thing on the page and the thing most readers take away. It
described two styles, and the middle column's view row said none.
That was true of the style as it was first written — a JSON API with a JavaScript front end genuinely has no server-rendered views. It stopped being true the moment somebody in that style needed a page a crawler can read, or a form that works without JavaScript, and added a controller returning HTML. Which is a normal thing to do, has always worked, and the guide was quietly telling them was not a supported option.
A comparison table is a claim like any other. It just does not read like one, because it looks like a summary of the page rather than an assertion about the framework.
The third column¶
There is now one, and it is deliberately not a third project layout:
| Services + API + SPA | Services + server-rendered pages | |
|---|---|---|
| Domain layer | src/Services |
the same services |
| View layer | none — a JS SPA consumes the JSON | src/Views, fed from services |
It is the second style with server-rendered pages beside the JSON. The services are the same objects; only what consumes them differs.
And the thing that had to be said once, plainly¶
No model is required for a view. View::addModel() is the only place
Pramnos\Application\Model is structurally needed. Skip it and $this->model is
false in the template — the no model case, not an error. Controller::getModel()
type-checks nothing at all.
$view = $this->getView('Directory');
$view->stations = (new StationDirectory())->live(20, 0);
return Response::make((string) $view->display('index'));
That sentence is on the page now because its absence had a cost: a project reasoned from "the MVC layer needs models" to "we must convert 66 services", when what the MVC layer actually needs is a controller, a template, and data of any shape at all.
Documentation¶
- Application Styles guide — a third
column, the note about what the second one used to claim, and one paragraph stating
that a view needs no model. Two new
use_cases:entries so the page answers the questions that led here.
The redirect the guide promised¶
The Validation guide describes flash-and-redirect as something the framework does for
you. It does — inside Application::exec(), which an application routing with
Router::dispatch() never calls.
Where the behaviour lives¶
Application::exec() catches ValidationException, writes _validation_errors and
_old_input into the session, and redirects to the referer. The form redraws itself
with the errors and the visitor's typing intact, and nothing in a controller has to
know about any of it.
That is the MVC request cycle. An application with a thin dispatcher and
Router::dispatch() — the layout the Application Styles guide recommends for a JSON
API — gets an uncaught exception where this page promises a redirect, and no
sentence anywhere says why.
Third time this week the same shape has turned up: a capability implemented once,
inside the kernel, and unreachable from the routing style the framework also
recommends. ApiDebugMiddleware was the first, the maintenance response the second.
It catches only ValidationException. A middleware that swallowed everything would
turn a real fault into a redirect back to the form, which is the most confusing outcome
available: the visitor sees the page again with nothing wrong on it.
And one bug not copied¶
Application::exec() redirects to $_SERVER['HTTP_REFERER'] ?? URL. When URL is
defined and empty — which it is under test, and can be in a misconfigured
install — that is a redirect to the empty string. A redirect to nowhere is a worse
outcome than the uncaught exception it replaced, so the middleware checks the constant
for content rather than existence and falls through to /.
Found by a test asserting the fallback goes somewhere, which is the kind of assertion that looks like padding until it fails.
Two session conventions that do not interoperate¶
Documented rather than changed, because both have users:
| Written by | Keys | Read by |
|---|---|---|
Request::validate(), Application::exec(), this middleware |
_validation_errors, _old_input |
$this->errors in a view; Request::old() |
FormRequest::failWith() |
_form_errors, _form_old_input |
FormRequest's own statics |
A view using $this->errors sees nothing after a FormRequest failure. The form
redraws with no errors on it and no indication why — which reads as "validation is
broken" rather than as "two conventions", and sends whoever hits it into the validator.
Neither guide said so. One of them does now.
Added¶
Pramnos\Http\Middleware\ValidationRedirectMiddleware— theApplication::exec()validation flash, available to a router-dispatched application in one line, with an optional fixed redirect target.
Documentation¶
- Validation guide — where the redirect is
implemented and what that means if you do not call
exec(), plus the two session conventions side by side.
The cache category was accepted and discarded¶
Cache::getInstance('views') returned an instance with whatever category the first
caller in the process had asked for. In any application that boots providers, that
first caller is CacheServiceProvider, which asks for none.
Nine lines, one static¶
public static function getInstance($category = NULL, $extension = NULL, ...)
{
static $instance = null;
if ($instance === null) {
$instance = new Cache($category, $extension, $method, $settings);
}
return $instance;
}
One instance. Not one per category — one. Every argument after the first call was ignored, and nothing said so.
That would be a curiosity if the category were decorative. It is not: $this->category
is what _generateCacheName() writes into the key, and save() has no category
parameter at all, so an instance's category is the only thing deciding where its
values go. remember() goes through both.
What it cost, in two directions¶
View::cache()believed it was writing underviews. It was not, sophp pramnos cache:clear --category=viewsnever matched a single view fragment. The command ran, reported success, and cleared nothing — which is the failure mode this ledger has been collecting all week, in a new place.- Two subsystems asking for different categories shared one namespace. A key collision between unrelated parts of an application was possible where the API said it was prevented.
And the guide documented the behaviour that did not exist, in detail — three instances with three categories, each saving its own data. All three were the same object.
The fix, and what it costs you¶
One instance per (category, extension, method).
Existing entries were written under the wrong key, so they miss once. For a cache that is the correct outcome rather than something to migrate: the values are recomputed and written where they belong.
$settings is deliberately not part of the key. It is merged over the
application's cache settings, and a caller passing different settings for the same
category is asking for a different configuration of the same store — which is what
new Cache() is for.
How it was found¶
Not by a report. It was found while planning something else entirely: a persistent cache for database column metadata needed a category of its own, and the first question was whether categories worked. They did not, and the thing that was going to use them has not been built yet.
Four of the five tests fail when the fix is reverted. The fifth is the one asserting
that asking twice for the same category still gives you one instance — getInstance()
must not quietly become a factory that opens a connection per call.
Fixed¶
Cache::getInstance()keeps one instance per(category, extension, method)instead of one for the whole process.
Documentation¶
- Cache guide — a correction on the Categories and Organization section, which described the intended behaviour accurately while the code did something else, and a sentence saying plainly that a category is a namespace rather than a label.
A marker nothing flipped¶
A consumer migrating off the legacy document type found that $modernizr — default
true, injected on every page — has no counterpart in the modern one. Checking it
turned up something they had not reported: the modern document still emits the marker
that script existed to change.
The report¶
Legacy pramnos_document_html carried public $modernizr = true; and put
into the <head> of every page. Pramnos\Document\DocumentTypes\Html has no such
property and never emits the tag. Zero occurrences of modernizr anywhere in src/.
Same for $reset and its reset.css.
Neither is being restored, and the reason is not indifference: the framework does
not ship either file. Reinstating a default-on injection of media/js/modernizr.min.js
would give every upgraded application a 404 in its <head> to replace a feature most
of them were not using. The Upgrade guide now says so, with the one-liner to add it
back.
The part that was not in the report¶
Html::render() still emitted <head class="no-js" …>.
no-js exists for exactly one purpose: a script replaces it with js so stylesheets
can tell whether JavaScript ran. Remove the script and the marker becomes a permanent
claim that JavaScript is off — so .no-js .thing { display: none }, which is the
standard progressive-enhancement pattern, hides that thing forever, in a browser
with JavaScript working perfectly.
Removing a feature and leaving its footprint is worse than removing it cleanly, because
the footprint reads as deliberate. Anybody auditing the markup sees no-js and concludes
the mechanism is present.
And it was on the wrong element besides. <head class="no-js"> cannot be matched by
any stylesheet — the head is not rendered, so head.no-js selects nothing. Modernizr
puts its classes on <html>, which is what makes the pattern work at all. So the marker
had never done anything, even while the script was being injected.
Fixed¶
<html class="no-js" lang="en">
<head>
<script>document.documentElement.className=document.documentElement.className.replace(/\bno-js\b/,'js');</script>
Two lines, no external file, no dependency. .no-js and .js now behave the way every
guide on progressive enhancement says they do. An application whose stylesheets were
written against the legacy behaviour starts working rather than stopping.
The test asserts both halves — the class on an element CSS can reach, and something that flips it — because either alone is worse than neither. A marker nothing changes is a lie about the browser; a flip script with nothing to flip is dead code.
The general shape¶
This is the third variant this week of the evidence says the feature is there: a guide naming methods that never existed, a usage example describing a workaround that could not work, and now a class attribute implying a mechanism that had been deleted. All three were found by running something rather than reading it.
Fixed¶
no-jsmoved from<head>(where no stylesheet can match it) to<html>, with an inline script that turns it intojs.
Documentation¶
- Upgrade guide — Two features the legacy document
had, and the modern one does not: why
$modernizrand$resetare not coming back, how to add either yourself, and what changed aboutno-js.
The HTML document could not say what page it was¶
setCanonical() and addStructuredData(), and a Pramnos\Html\Seo for pages built
without a Document. The HTML document type had no canonical property at all — only
AMP did.
What was there¶
For a canonical, one route:
Which means every application escapes the URL itself, or does not. For structured data,
the same, with a trap: the method whose name is closest, addInlineScript(), hardcodes
a bare <script> with no type and appends it to the foot. Following it hands
your JSON-LD to the browser as JavaScript.
Both now have a method, and the same strings are available as
Seo::canonicalLink($url) and Seo::jsonLd($data) for a page assembled from a layout
template rather than through a Document. One implementation, two ways in — a second
copy of the encoding rules below is how the two drift.
The flags are the feature¶
JSON_HEX_TAG is the one that matters. It is the only injection this format has: a
</script> inside any value ends the block early, and everything after it is parsed as
markup. Structured data is assembled from record titles and operator-written
descriptions — precisely where such a string arrives from.
The other two are about the block being readable: without them every URL becomes
https:\/\/… and non-Latin text becomes \uXXXX. Both are valid JSON and both make
the block unreadable in view-source, which is the only place anybody ever checks it.
And when the data cannot be encoded at all — a resource handle, invalid UTF-8 —
nothing is emitted. json_encode() returns false, which concatenated into a
script tag is an empty one. A page without structured data is a smaller problem than a
page with a broken script block in its head.
Two decisions worth stating¶
Blocks are not merged. Each addStructuredData() call emits its own script. A
station page carries the station and its breadcrumb trail; merging two @types into one
object produces something no validator accepts.
AMP keeps exactly one canonical. It computes one when none is set and has emitted it
since long before this, so the shared helper deliberately does not add a second. Two
<link rel="canonical"> on one page is undefined behaviour to a crawler — worse than
having none, because it looks handled.
What the framework will not do for you¶
Omit a key you have no value for rather than emitting "genre": "". An empty string is
a claim that the field is blank, which is a different statement from not making the
claim, and consumers treat it as one. This cannot be automated: the framework cannot
tell a deliberate empty string from a lookup that failed, and guessing would be worse
than either.
Added¶
Document::setCanonical()andDocument::addStructuredData(), rendered by the HTML document type and — structured data only — by AMP.Pramnos\Html\Seo::jsonLd()andSeo::canonicalLink(), for pages built without aDocument.
Documentation¶
- Document & Output guide — the Canonical
links and Schema.org structured data sections rewritten against the new methods,
with the encoding flags in a table and a note on why
addInlineScript()is the wrong neighbour to reach for.
getData() and the columns it never returned¶
It kept only values that were is_numeric() or is_string(). So NULL columns were
absent rather than null, booleans vanished, decoded JSON columns vanished, and a model
that declared no public properties returned an empty array.
One cause, four symptoms¶
foreach (get_object_vars($this) as $key => $value) {
if ($key == '_primaryKey' || $key == '_dbtable' || ...) { continue; }
if (is_numeric($value) || is_string($value)) { $data[$key] = $value; }
}
The type filter exists because the loop scans the whole object — it is what stops
_initialData, the controller and the message buffers from ending up in a payload. It
does that by dropping every array, object and boolean, and the columns are collateral.
Four consequences:
NULLcolumns are absent, notnull.array_key_exists()says "this record has no such field" about a field the record has.- Booleans and decoded JSON columns vanish.
- A model declaring no public properties returns
[]. Columns assigned to an undeclared property go throughBase::__setinto_data, which the loop sees as one array and drops whole, columns inside it and all. sqlErrorwas not on the exclusion list. It is a string once a query has failed, so a failed read put its SQL error message into whatever was being serialised.
The generator already knew. make:crud emits per-column casts that put booleans back
after calling parent::getData() — patching the base's type filter one type at a time,
and stopping one short of JSON. That case exists now too.
Why it changed, measured rather than assumed¶
The obvious fix is to name every internal and drop the type filter. The blast radius was
measured first, on an application with 54 models, 45 overriding getData(), and 42 of
those calling parent::getData() — so a change here reaches almost every endpoint it
has.
Running the old and new implementations side by side against those real model classes:
| models that gain keys | 48 of 54 |
| keys added | 523 (avg 10.9 per model) |
of which NULL |
411 |
| boolean | 53 |
| array | 55 |
And the measurement produced the argument for the change rather than against it. Those same overrides do:
$data = parent::getData();
$data['reportid'] = (int) $data['reportid'];
$data['date'] = (int) $data['date'];
Unguarded. So a record with NULL in one of those columns raised Undefined array key
in production, and (int) null put a 0 in the payload where the value was NULL.
The absent key was not a neutral quirk; it was producing warnings and wrong numbers in
the application that had lived with it longest.
So the default now returns everything, and the historical shape is available as an opt-out for anybody who needs it back:
Adding a parameter was never available: getData() is overridden in dozens of places
with no arguments, and PHP treats a child with fewer parameters than its parent as a
fatal declaration error. Checked before designing around it.
The golden master, and the flaw in the first one¶
The claim "the default is byte-identical" is checkable by machine, so it is checked:
the old algorithm is transcribed into the test model and compared with serialize() —
key order included, since a payload with the same keys in a different order is a
different JSON document.
The first version put that transcription on the test class, and it was wrong in a way that made it agree.
get_object_vars() returns what the calling scope can see. From inside the model,
protected properties are included; from the test class, only public ones. So the
transcription never saw sqlError — the property the whole exercise had identified as
leaking — and the comparison passed by coincidence of the data rather than by running
equivalent code.
A golden master that cannot see what the original saw is not one. It is a method on
the model now, and the test that exposed it is the one asserting that sqlError is the
only difference.
Six of the fourteen tests fail if the default is flipped to full fidelity.
Fixed¶
getData()returnsNULL, boolean and array columns instead of dropping them, and reads the_databag for models that declare no public properties.- It no longer returns
sqlError, in either mode. make:crudgenerates a case for JSON columns, which had none.
Added¶
Model::$getDataFullFidelity— set it tofalsefor the pre-1.2 shape, byte for byte.
It also got 8.5× faster¶
Not the point of the change, but worth recording because the shape of the win was not where it looked:
| µs per call | |
|---|---|
| the original | 12.143 |
exclusion list as an isset() lookup instead of eight chained == |
5.340 |
array_diff_key() instead of the loop |
1.422 |
get_object_vars() alone is 0.949 µs of that last figure, so what remains is 0.34 µs of
overhead and there is nothing further to win.
A page of 50 rows through useGetData went 0.797 ms → 0.271 ms, while returning
more data than before. The opt-out improved too — 6.617 → 4.183 µs — because the
internals are removed before the type test runs, so the loop covers twelve columns
rather than thirty-one properties.
One optimisation was measured and rejected: skipping the array_merge when the
_data bag is empty saves nothing (1.299 µs against 1.287), because merging an empty
array is already cheap. It would have been a branch earning its keep in nobody's
benchmark.
The merge order was wrong first¶
Where a column exists both as a declared property and in the _data bag, the declared
property wins — it is the live value; the bag is the fallback for columns nobody
declared.
The first implementation had array_merge($source, $this->_data), which is the other
way round, so a stale bag entry shadowed the property. That presents as a field that
stops updating: no error, no warning, a value that is simply old. It was caught by the
one test written for precedence rather than for output, which existed only because the
merge looked too obvious to leave untested.
Documentation¶
- API guide — which columns reach a payload and which do not, how to opt in, and what to check afterwards.
An alarm that stays rare¶
Opt-in email when an account is signed in to from a browser or device it has not been used from before. The hard part was not detecting it — it was not firing.
The requirement that shaped everything¶
No notification on a new IP address. Consumer connections are dynamic, so an address-based alarm fires on a router reboot, and by the second week nobody reads it.
That constraint has a less obvious sibling, and it is the one that would have
shipped: a fingerprint built from the User-Agent string changes every time the
browser updates. Chrome and Firefox ship a major version about every four weeks. That
is a monthly alarm for every user — the same failure, one step removed, and invisible
in a test that only checks the fingerprint is a string.
So SignInFingerprint keeps a browser family and a platform family, and nothing
else:
Chrome/109 … Windows NT 10.0 → chrome|windows
Chrome/133 … Windows NT 10.0 → chrome|windows ← a year later, same value
iPhone OS 17_2 … Safari → safari|ios
iPhone OS 17_4 … Safari → safari|ios ← after an OS update
Most of its tests are stability tests: browser update, OS point release, x64 to ARM64 — one value. The discrimination tests are the easy half.
The cost, stated rather than hidden: two Chrome-on-Windows machines are indistinguishable, so a colleague's identical laptop raises nothing. That is the price of rarity, and rarity is the entire value. An application needing more should add a signed device cookie — narrowing the user-agent parsing is the wrong lever.
One trap on the way: Edge announces itself as Chrome, Chrome announces itself as
Safari, and everything announces itself as Mozilla. Matched in the wrong order, every
desktop browser collapses into safari and the feature silently stops detecting
anything. There is a test for the ordering.
The day-one problem¶
A device detector with no history says everything is new. Switch it on, and every user who opted in is notified at once — about a sign-in they are performing right now.
So the history comes from authserver.user_activity_log, which has recorded a user
agent against every login since the auth feature shipped. Months of it. The first
sign-in after upgrading is recognised as familiar, which is what it is.
An account with no history is treated as not new, for the same reason. And only
successful logins count: login_failed carries a user agent too, and letting a failed
attempt make a browser familiar would turn the audit log into a way of switching the
alarm off.
No migration¶
The opt-in is a userdetails row — the framework's per-user key/value store, where
password-reset tokens already live. No schema change, so it works on every installation
the moment the framework is upgraded, including those whose migration_cutoff skips
baseline migrations. It inherits that table's cascade on user deletion, which a
GDPR-relevant preference needs anyway.
A column on user_privacy_settings was written first and thrown away. It was tidier and
it would have left every installation waiting on a migration to get a security feature.
What the email does not do¶
It does not print the IP address. Nobody recognises their own, and printing one invites the compare-with-last-time habit this feature exists to avoid.
It offers no link. A link in an unexpected security email is the shape of the attack it is warning about; the instruction is to open the site yourself.
It goes by mail only. A database notification would put the warning in the panel of the session that triggered it — in the case worth warning about, the wrong person.
Two mistakes the build caught¶
NewSignInAlert resolved its own connection through Factory::getDatabase(), so the
integration test could not point it at the test database. That is the lesson
Service::database() already documented
in this framework, with 59 call sites behind it, arrived at again from the other end.
Every method takes an optional connection now.
And authserver.user_activity_log resolves to authserver_user_activity_log on
MySQL — the schema becomes part of the name. The test created the plain
user_activity_log on a confidently-worded assumption, so four tests failed reporting
"no history" rather than "wrong table" — a failure that points away from its cause.
Added¶
Pramnos\Auth\SignInFingerprint— coarse, stable browser/platform identity.Pramnos\Auth\NewSignInAlert— the opt-in, the history lookup, and the check.Pramnos\Auth\Notifications\NewSignInNotification.- A checkbox on the Account privacy page, in all three scaffolded themes.
- The login lifecycle records the fingerprint in the activity log's details.
Documentation¶
- Authentication guide — New sign-in alerts: what counts as new, what deliberately does not, where the history comes from, and why the email says what it says.
A column that described data nobody wrote¶
usertokens.deviceinfo is declared as "JSON-encoded device/client information
(browser, OS, IP at token creation)". Token has decoded it for years. addToken()
wrote ''.
The evidence all pointed one way¶
The column exists. Its comment says what it holds. Token::load() handles both a
serialised value and JSON for it. Token exposes deviceinfo as an array. Everything
about the code says this is a populated field.
That is every session, every API token, every OAuth exchange, since the table was created.
The visible cost is the active-sessions list. It exists so somebody can look at their sessions and recognise which is which — and it had nothing to recognise them by. A column that is empty everywhere reads as "this installation has no device data", not as "nothing ever wrote any".
What goes in it now¶
Three keys, and the choice of three is the point.
device is the coarse SignInFingerprint,
not the raw user agent. Storing the agent would make every session look like a different
device after any browser update — a list meant for recognition, rendered as a list of
strangers. It is also the longest thing that could go in this column, on a row written
at every login.
label is the same thing in words, because chrome|windows is not what a person
scanning their own sessions needs to read.
ip is recorded because an administrator investigating an incident needs it. It is
not used to decide anything, for the reason given at length in the sign-in alerts:
consumer addresses are dynamic, and comparing them is how a security signal becomes
noise.
One format, two writers¶
Worth confirming rather than assuming, because Token::load() reads two:
if (Helpers::checkUnserialize($this->deviceinfo)) {
$this->deviceinfo = unserialize($this->deviceinfo); // legacy rows
} elseif ($this->deviceinfo && json_decode($this->deviceinfo) !== null) {
$this->deviceinfo = json_decode($this->deviceinfo, true); // everything written today
}
That reads like a format split. It is not. Token::save() writes
json_encode($this->deviceinfo) unconditionally, whatever it is handed — array or
object — and addToken() now does the same. The unserialize() branch is a reader for
rows an older path left behind, and nothing produces that shape any more.
So an application that sets deviceinfo itself later in the request — one does, with
Helpers::getBrowser(), which returns an object — round-trips through the same encoder
and comes back through the same branch. The framework fills the column at creation; the
application enriches it afterwards; neither has to know about the other.
Two failure modes closed on the way¶
json_encode() returning false would put the literal false into a TEXT column that
Token::load() then tries to decode — a token that cannot be read back is worse than
one with no device information. And issuing a token must not fail because the request
could not be described: every caller is inside a login or an OAuth exchange, so the
whole thing is wrapped and degrades to an empty string, which is exactly what it wrote
before.
No signature changed. addToken() computes this itself, so every existing caller gets
it without knowing.
The third one this week¶
This is the same shape as two other findings in the last few days, and worth naming as a class: a control described more strongly than it was built.
- A guide section naming three methods that never existed.
class="no-js"implying a mechanism that had been deleted.- A column comment describing data nothing ever wrote.
None of the three was found by reading the code — reading it is what makes them convincing. All three were found by running something and looking at the output.
Fixed¶
User::addToken()populatesusertokens.deviceinfowith the device fingerprint, a readable label, and the client IP, for every token type.
Documentation¶
- Authentication guide — What a session record contains.
Fourteen tabs do not fit¶
The debug toolbar had grown a tab at a time until the row ran off the side of a laptop screen. Related tabs group into dropdowns now, every tab explains itself, and the DevPanel link stops advertising a door not everyone may open.
Grouping, and the failure it had to avoid¶
| Group | Tabs |
|---|---|
| App | Route, Views, Domain, Migrations |
| User | Auth, Gate, Session |
| Logs | Logs, Exceptions, Errors |
SQL, Time, Client and API stay inline — they are opened most often, and a click to reach them would be a click too many.
The obvious way to get this wrong is to hide a problem inside a collapsed menu. Three rules prevent it, and each is the answer to a "why did that tab move":
- A group containing something alarming is marked as alarming. An exception you cannot currently see still shows as a warning on the group holding it.
- The open tab is pulled out of its group and rendered on the bar, so selecting a tab does not make it vanish into a menu.
- A single-item group is not a dropdown. A menu holding one item is a worse way to click that item.
Every tab also carries a tooltip now — Gate is authorization policy and permission
checks, Domain is domain model entities loaded. Short labels are good for a crowded
bar and bad for anybody who has not read the source.
The DevPanel link¶
Two changes, and they are different in kind.
Its address is resolved server-side from sURL, falling back to SITE_URL for
applications that define that instead, with a JavaScript fallback behind both. An
installation served from a subdirectory gets a link that works rather than one that
guesses at the root.
It is hidden unless the devpanel feature is enabled and the signed-in user meets
devpanel.min_usertype (default 90).
That second one is worth being precise about, because "hide the link" is a phrase that
usually means security theatre. It is not the control here: DevPanelController already
performs the same feature-and-usertype check on every action it serves, and it did
before this change. Removing the link stops advertising a door that is locked either
way — defence in depth on top of enforcement, rather than in place of it.
Housekeeping¶
demo.php — a scratch harness that rendered a toolbar from fabricated collectors — was
committed to the repository root and not listed in .gitattributes, where /tests,
/build, phpunit.xml and Vagrantfile already are. So it shipped inside the composer
package, to vendor/mrpc/pramnosframework/demo.php in every consuming application.
It could not run there — it requires __DIR__ . '/vendor/autoload.php', which from
that location points at a directory that does not exist. What it could do is sit at a
predictable path as an executable script that constructs and renders a debug toolbar,
for any installation whose web server serves vendor/. Misconfiguration, but a common
one, and not a risk worth carrying for a file that cannot work.
Deleted rather than export-ignored. A demo that only runs from a checkout belongs wherever the tests live, not beside the code it demonstrates.
And it had already changed the output of a generator¶
The file did not sit there inertly. project:git-webhook writes a header telling the
operator how the script was produced, and the full suite came back with:
MakeWebhook::detectCliName() looked for the console entry point by taking the first
*.php in the project root that was not one of three excluded names. demo.php sorts
early and was not on the list, so it became the application's CLI name — in a generated
file, written to disk, telling somebody to run a command that does not exist.
A blocklist here has to enumerate everything that is not the answer, which is
unbounded, and it was wrong the first time a file appeared that nobody had thought of.
It identifies positively now: a console entry point constructs the application's
Console and runs it, and a stray script does neither.
There was a test covering that fallback, and it asserted the fragility as the
contract — its fixture was a file containing <?php // entry point, a comment that
proves nothing about being one. It now plants a real entry point beside a stray that
sorts before it, so it fails if position ever beats content again.
Worth noting how this surfaced: not from reading the diff, which is why the file was only flagged as "this ships and cannot run". The suite failed on an unrelated test and the failure named the cause in its own output.
Added¶
- Category dropdowns in the debug toolbar, with the alarming, active-tab and single-item rules above.
- A tooltip on every tab and every chip.
devpanel_urlresolved fromsURL/SITE_URLserver-side.
Fixed¶
- The DevPanel link is not shown to users who could not open it.
demo.phpno longer ships to consuming applications.
Documentation¶
- Debug toolbar usage — Finding the tab you want, and what the DevPanel link's visibility does and does not protect.
Two things that did not complain¶
Reported from consuming the framework in another project. Neither is fatal; both are the kind that stay invisible because the thing that should object does not.
project:resync reported success for a write that failed¶
file_put_contents($abs, $content); // return value discarded
$output->writeln(" <info>{$verb}d</info> {$rel}");
return $exists ? 'updated' : 'created';
Run as a user who cannot write the target:
Warning: file_put_contents(…/frontend/lib/debug.js): Permission denied
updated frontend/lib/debug.js
Done. 0 created, 1 updated, 0 unchanged, 0 skipped.
The reporter confirmed with diff that the file was byte-identical afterwards. Nothing
was written. The per-file line says updated, the summary says 1 updated, and the
exit status is 0.
The PHP warning is the only signal, it goes to stderr inside a wall of output, and a CI
job or a habitual 2>/dev/null throws it away. A caller checking the exit code — the
correct way to run this — was told the resync had succeeded.
That inverts the command's entire purpose. project:resync exists so a framework-owned
file downstream is the framework's current one. A resync that reports success without
writing means a project runs an old copy with confidence, which is precisely what the
command was built to prevent.
Both writes are checked now — the mkdir() above it was unchecked too — and a failure
is reported as failed, counted in a FAILED tally, and exits non-zero. The message
names permissions, because that is by far the likeliest cause and it is fixed by who
runs the command rather than by anything in the code.
The failed count is printed only when there is one. A 0 failed on every healthy run
is noise that teaches the reader to skip the line the one time it matters.
The test, and the first version of it¶
Reverting the fix must make it fail, or it is watching the wrong thing.
The first version made the target read-only with chmod 0444 — and the test container
runs as root, for whom the mode is advisory. It skipped. A skipped test is green,
which would have left the requested test not running at all.
It now puts a directory where the file should be. file_put_contents() on a
directory fails for root as well, so it runs everywhere. A second test covers the
mkdir() branch by making the parent a regular file. Both fail when the fix is
reverted, with the warnings naming line 230 — the line the report named.
A duplicate var that broke a consumer's build¶
debugbar.js declared var hasMvcPage twice in the same scope. Harmless at runtime —
var redeclaration is legal and both assigned false — and an error under
no-redeclare, which is in eslint:recommended.
That file is not a build artifact. project:resync copies it into consuming projects,
where their tooling runs over it. In the reporting project the test runner lints before
it runs, so 1,195 tests stopped running, none of them about the debug panel. Their
workaround was a per-file ESLint override plus a tripwire to remember to remove it.
The second declaration is gone; the one that remains carries the docblock.
On preventing the next one — and a guard that was written and thrown away¶
The report suggested linting the asset in CI, and that is right. This repository has no
package.json, no lint configuration and no test workflow at all, so adding ESLint is
infrastructure to decide on rather than something to attach to a bug fix.
A zero-dependency substitute was written instead: scan the shipped assets for an
identifier declared with var more than once. It failed on its first run — on
var rows, declared in six different functions, which is legal and not what
no-redeclare means.
So it was deleted. It matched a name rather than a construction, which is the exact failure this changelog has been recording all week, committed by the check meant to prevent one. The reporter had said a unit test for this would be worse than the linter that already catches it; that turned out to be a prediction rather than an opinion.
The honest position: the recurrence risk is open. Real scope analysis needs a
parser, a parser means a dependency, and the dependency means package.json plus a CI
workflow — worth doing, and worth doing deliberately.
Fixed¶
project:resyncchecksfile_put_contents()andmkdir(), reportsfailed, tallies it, and exits non-zero.debugbar.jsno longer declareshasMvcPagetwice.
Documentation¶
- Console guide — what a failed resync looks like and why the exit code is the thing to check.