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¶
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:
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&into&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\HtmlandAmpescape every value they render into the<head>: title, description, allog_*, meta-tag names and values, AMPcanonical,langandcharset.- A
null, array or object in one of those slots now renders as empty instead of raising a deprecation or aTypeErrormid-<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 OKwith 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¶
MaintenanceModeMiddlewarewatchesvar/MAINTENANCEas well asmaintenance.flagwhen constructed with no argument; an explicit path is still exclusive.Application::showError()sends503(maintenance) or500(fault), a matchingContent-Type, andRetry-Afterwhile stopped on purpose.- The same call answers JSON to clients that asked for it, instead of HTML with a
200. PRAMNOS_MAINTENANCE_RETRY_AFTERsets the retry window for both paths.MaintenanceModeMiddlewaregained the docblocks the rest of the middleware has, and no longer callsheader()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.