Skip to content

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:

Wrote 1 path(s), 1 operation(s) to /srv/app/www/api/openapi.json

Nothing there is false. It did write one path, it did write it to that file. What it did not say is where it lookedsrc/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 --controllers was passed, because naming the directory is a decision, not a guess to correct.

  • The default looks for the API first: src/Api/Controllers, then src/Controllers. An application with only the second is unaffected.

  • The output follows the document root — whichever of www, public, html, web holds an index.php. Hardcoding www/ stopped being defensible the moment pramnos init grew --web-root: a project scaffolded with --web-root=public had this command create a www/ 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:docs prints 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.
  • --controllers defaults to the first of src/Api/Controllers, src/Controllers that exists.
  • --output defaults to <document root>/api/openapi.json, detected rather than assumed to be www/.
  • --namespace is derived from the controllers directory instead of a fixed \Controllers suffix — 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:

http_response_code(): Calling http_response_code() after header('HTTP/...') has no effect

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

header('HTTP/1.1 200 OK');

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() and Rss::render() no longer write a status line, so the status the caller set survives being rendered.
  • CorsMiddleware answers preflight with http_response_code(204) instead of header('HTTP/1.1 204 No Content') — the hardcoded HTTP/1.1 is also wrong on an HTTP/2 connection.

Documentation

  • Document & Output guideThe 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:

Exception: Application::close() called with msg: {"error":"unavailable", …}

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 sets sqlError and returns an empty list, which is what ApiListResponse::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 guideWhen 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 — a Controller for code with no MVC request behind it, with shared() and forget().

Fixed

  • Orm\Relations\Relation::newRelatedInstance() no longer documents a reason for the controller that was never true.

Documentation

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.php
  • ROOT/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:

<!--
View Rendered at: 16/08/2026 11:04:22
View Path: /src/Views/Home/home.html.php
-->

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() searches dirname($this->path)src/Views for 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 Path comment is emitted in debug mode only.

Documentation

  • Framework guideLayouts 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.

$router->addGlobalMiddleware(new ValidationRedirectMiddleware());

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 — the Application::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 under views. It was not, so php pramnos cache:clear --category=views never 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

<script async src="<?= sURL ?>media/js/modernizr.min.js"></script>

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-js moved from <head> (where no stylesheet can match it) to <html>, with an inline script that turns it into js.

Documentation

  • Upgrade guideTwo features the legacy document had, and the modern one does not: why $modernizr and $reset are not coming back, how to add either yourself, and what changed about no-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:

$doc->addHeadContent('<link rel="canonical" href="' . $url . '">');

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_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE | JSON_HEX_TAG | JSON_HEX_AMP

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() and Document::addStructuredData(), rendered by the HTML document type and — structured data only — by AMP.
  • Pramnos\Html\Seo::jsonLd() and Seo::canonicalLink(), for pages built without a Document.

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:

  • NULL columns are absent, not null. 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 through Base::__set into _data, which the loop sees as one array and drops whole, columns inside it and all.
  • sqlError was 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:

protected $getDataFullFidelity = false;   // the pre-1.2 shape, byte for byte

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() returns NULL, boolean and array columns instead of dropping them, and reads the _data bag for models that declare no public properties.
  • It no longer returns sqlError, in either mode.
  • make:crud generates a case for JSON columns, which had none.

Added

  • Model::$getDataFullFidelity — set it to false for 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 guideNew 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.

'deviceinfo'  => '',

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

{"device": "chrome|windows", "label": "Chrome on Windows", "ip": "203.0.113.9"}

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() populates usertokens.deviceinfo with the device fingerprint, a readable label, and the client IP, for every token type.

Documentation

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.

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:

Generated by: php demo project:git-webhook

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_url resolved from sURL / SITE_URL server-side.

Fixed

  • The DevPanel link is not shown to users who could not open it.
  • demo.php no longer ships to consuming applications.

Documentation

  • Debug toolbar usageFinding 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:resync checks file_put_contents() and mkdir(), reports failed, tallies it, and exits non-zero.
  • debugbar.js no longer declares hasMvcPage twice.

Documentation

  • Console guide — what a failed resync looks like and why the exit code is the thing to check.