Skip to content

15 August 2026

2 changes:

  • One quote in a station name
  • Two flags that never met

One quote in a station name

Every document type built the <head> by concatenation, so a " in a title ended the attribute and everything after it was markup. And the guide section describing that head documented three methods that do not exist.

The head was never escaped

'<meta name="description" content="' . $this->description . '" />'

That line, and fifteen like it, in DocumentTypes/Html.php and again in Amp.php. Title, description, all six og_* slots, both meta-tag loops — names as well as values — and the AMP canonical. All interpolated raw.

It went unnoticed for a reason worth naming: for most of this framework's life the values were developer-written constants. A page whose title is 'Dashboard' is safe no matter how it is concatenated. The moment those values start coming from a database — a record's name, operator-written copy, anything a user submitted — the same line is an injection point, and it is in the part of the page nobody reads.

Fixed with one shared helper on Document rather than sixteen inline calls, so the two renderers cannot drift:

protected function escapeHeadValue($value)

Three decisions inside it, each of which would be a bug the other way:

  • ENT_QUOTES — these are attribute values, and single quotes matter as much as double.
  • ENT_SUBSTITUTE — without it, htmlspecialchars() returns '' for the whole value when the input is not valid UTF-8. One bad byte in one row would silently erase the entire page title. A visible replacement character is a much cheaper failure than a blank <title> nobody can trace.
  • double_encode: false — an application that already escapes its own metadata is doing the right thing, and turning its &amp; into &amp;amp; would punish it for that.

What is not escaped, deliberately: headContent, addHeadTagContent(), extraHtmlTag, extraBodyTag and header. Those exist to carry markup. Escaping them would break every application using them as documented, and the breakage would present as <link> tags showing up as visible text — obvious, but only after a deploy.

The tests are split the same three ways: values that must be escaped, values that must not, and already-escaped input that must survive untouched. Six of the ten fail without the fix; the other four are the guards that would catch over-applying it.

And the guide described an API that does not exist

The Meta Tags and SEO section told you to call addMetaName(), addMeta() and addScriptDeclaration(). None of the three exists. None ever did. Code copied out of that section failed with Call to undefined method.

The real API is one method with a flag:

$doc->addMetaTag('robots', 'index, follow', true);   // <meta name="…">
$doc->addMetaTag('og:article:author', 'Author');     // <meta property="…">

The structured-data example was worse than wrong. It pointed at addScriptDeclaration() with an application/ld+json argument, implying the framework knows about JSON-LD. It does not — and the method that sounds closest, addInlineScript(), hardcodes a bare <script> with no type and appends it to the foot. Following the shape of the old example with the nearest real method would have handed your JSON-LD to the browser as JavaScript.

The section now shows addHeadContent() with the encoding flags spelled out and the reason for each — JSON_HEX_TAG in particular, because a </script> inside any value is the one injection JSON-LD has.

The two are the same failure

A section documenting methods that were never implemented is the same defect as a head that was never escaped: both are things nobody exercised, in an area that only became load-bearing when pages started being assembled from data. The escaping bug needed code; this one needed somebody to run the example.

Fixed

  • Document\DocumentTypes\Html and Amp escape every value they render into the <head>: title, description, all og_*, meta-tag names and values, AMP canonical, lang and charset.
  • A null, array or object in one of those slots now renders as empty instead of raising a deprecation or a TypeError mid-<head>.

Documentation

  • Document & Output guide — the Meta Tags and SEO section rewritten against the real API, plus a new subsection stating exactly which values the document escapes for you and which remain yours to escape.

Two flags that never met

MigrationRunner enables maintenance mode by raising var/MAINTENANCE. MaintenanceModeMiddleware watched maintenance.flag. An application that registered the middleware exactly as documented served every migration from the live site.

The flag nobody was watching

Three things in this framework put the site into maintenance, and they did not agree on the file:

Raised by Flag
Application::startMaintenance() var/MAINTENANCE
MigrationRunner, for the duration of a batch var/MAINTENANCE
MaintenanceModeMiddleware maintenance.flag

The middleware's own docblock said touch /path/to/maintenance.flag, and the guide's table said the same, so nothing about reading either would have told you. The operator adds the middleware globally, runs php pramnos migrate, and watches the runner announce that maintenance mode is on — while every request continues to be served, mid-migration, against a schema that is halfway between two shapes.

The failure is silent in the worst direction. It is not that maintenance mode does nothing visible; it is that the evidence for it working is right there — the middleware in the pipeline, the runner's message, the flag file on disk — and all of it is true. Three correct facts about two different files.

With no constructor argument the middleware now watches both. Pass a path explicitly and it watches that path alone: an application that named its own file has said which file it means, and silently adding two more would let the framework decide a site is down.

The tests assert which paths are watched, not that some flag stops a request. The latter would have passed for the entire life of the defect — which is exactly the shape this ledger keeps recording, and the reason to write the harder assertion.

And the response itself had no status

Application::showError() is reached whenever var/MAINTENANCE exists, because the constructor calls it — which includes applications that route with Router::dispatch() and never touch init() or exec(). It emitted an HTML page and nothing else: no status code, no content type.

Two consequences that look unrelated and are one bug:

  • A JSON client got 200 OK with a page of HTML. It failed on parsing, not on recognising that the site was down — so the SPA showed a generic error instead of a maintenance state it could have handled.
  • A crawler got the maintenance page as a 200, which makes it eligible to be indexed in place of the real page. For a site that renders on the server because of search engines, an hour of planned downtime could cost the result the page exists to earn.

Now:

Situation Status Body
Maintenance, browser 503 + Retry-After the HTML page, unchanged
Maintenance, Accept: application/json 503 + Retry-After {"error":"maintenance","retry_after":300}
Any other fatal — PHP version, addon, database 500 "error":"unavailable", no retry

The split matters: showError() is also the terminal fault path, and answering 503 Retry-After to a misconfiguration tells a crawler to come back to something that is not coming back.

Retry-After reads PRAMNOS_MAINTENANCE_RETRY_AFTER — a constant rather than a setting, deliberately. This runs while the site is down, and in the case that matters most, the database being why it is down, asking the database how long to wait cannot work.

Content negotiation is one header test: Accept naming application/json, or X-Requested-With: XMLHttpRequest. Browsers send neither, so there is no list of API paths to keep in step with the router — the thing that would rot.

The JSON body carries the same message the HTML page shows, under the same conditions (the developer-supplied message always, the database dump only under DEVELOPMENT). Carrying less would mean the format a client can actually parse is the one told least.

What made it findable

Both halves were found while writing documentation, not while reading code: the flag mismatch turned up because the guide's middleware table had to be checked against the class, and the missing status turned up because a consumer asked whether their SPA had a maintenance guard. It has one. It has had one all along, and its output was unusable — which is a different problem from not having one, and produces the same report.

Fixed

  • MaintenanceModeMiddleware watches var/MAINTENANCE as well as maintenance.flag when constructed with no argument; an explicit path is still exclusive.
  • Application::showError() sends 503 (maintenance) or 500 (fault), a matching Content-Type, and Retry-After while stopped on purpose.
  • The same call answers JSON to clients that asked for it, instead of HTML with a 200.
  • PRAMNOS_MAINTENANCE_RETRY_AFTER sets the retry window for both paths.
  • MaintenanceModeMiddleware gained the docblocks the rest of the middleware has, and no longer calls header() after headers are sent.

Documentation

  • Framework guide — a Maintenance mode section under Built-in Middleware: which flag each mechanism raises, what each kind of client is told, and the corrected table entry.