27 September 2026¶
3 changes:
- The mail-template editor had no way in for an application's own categories
getData()on an ORM model published the ORM's bookkeeping, including the write allowlistHttp\Clienthanded back a compressed body it had not asked for and never decoded
The mail-template editor had no way in for an application's own categories¶
Yesterday's registry gave the framework's four categories a title, a description and a placeholder list, and taught the editor to show all three. The reason it was written:
an operator opening Administration → Mail templates saw an empty list and had no way to learn that
auth.twofactor_codeexists, let alone that{code}and{minutes}are the things it may say.
An application sending its own mail through MailChannel got precisely that — a category
name, an empty body and nothing else. The sentence the registry exists to make false was
left true for everybody except the framework.
And placeholdersFor() said so in its own doc-block: an empty list is returned "for one
the framework does not declare — an application's own category, which it documents itself".
There was nowhere to do that documenting. An invitation to a door that was not there.
Three locked doors, each locked in a way that looked open¶
all()was a hard-coded literal with no registration.MailTemplatesController::placeholders()ispublic staticand was called asself::, notstatic::.Init.phpgenerates a subclass of that controller for every application and its doc-block invites overriding — so the override was reachable only by calling it directly, which is the one caller the framework never uses.view()andedit()return the rendered HTML, so an override callingparent::view()adjusts a string after the page has been produced.
The reporting project had a subclass with a placeholder override, view overrides and a
unit test that passed — because the test called the static method directly.
empty($string->categoryNote) does not warn, so three dead methods read as a working
feature for as long as nobody opened the screen.
Fixed¶
SystemMailTemplates::register(), merged behind the framework's own. An application registers from a service provider'sboot()and the editor answers for its categories the same as for the framework's — no subclass, no override.- The framework's own win a collision. An application cannot redefine what
auth.twofactor_codeadvertises: the notification supplying those variables is the framework's, and a disagreeing entry would put{like_this}in somebody's email with the screen's blessing. builtIn()for the two callers that must not see registrations — the seeding migration, which would otherwise invent rows for mail the framework does not send, and the drift test, which would fail on any category no framework notification answers to.self::placeholders()→static::inview(),edit()andtest(). Apublic staticmethod on a class the framework generates a subclass of is an extension point whether or not it was meant as one, and late static binding is what makes it one.
The test that would have caught it¶
RegisteredMailCategoryReachesTheScreenTest asserts on what the screen is handed, not
on what the registry answers. A registry that is right while the controller never asks it is
exactly the shape this failed in, and the reporting project's passing unit test is what that
shape looks like from inside. It also holds the subclass override, for the same reason.
Documentation¶
- Notifications Guide — "registering your own category": the call, where to make it, that the framework's own win a collision, and that registering is about what the editor shows rather than whether a row exists.
getData() on an ORM model published the ORM's bookkeeping, including the write allowlist¶
The JSON for one post began:
{ "_pendingScopes": [], "fillable": ["body","link","property_id","scheduled_at","…"],
"guarded": ["*"], "casts": {"post_id":"int"}, "timestamps": true,
"createdAtColumn": "created_at", "softDelete": false, "loadedRelations": [],
"eagerLoad": [], "post_id": 42, "…": "…" }
Thirteen keys of bookkeeping before the first column, in every endpoint answering an ORM model — to browsers and over MCP.
getData() filters get_object_vars($this) against Model::INTERNAL_PROPERTIES, and that
constant knows the base model's internals. OrmModel declares fourteen of its own, across
the traits it uses, and nothing added them — so they read as columns.
fillable and guarded are the write allowlist. Publishing them tells a caller exactly
which fields an endpoint will accept from a request body, which is otherwise something an
attacker has to guess at. That is the half worth fixing at the source rather than in each
application.
Fixed¶
Model::internalProperties(), called asstatic::fromgetData(), so the answer belongs to the class the instance actually is. A constant could not do it: a subclass redefining one shadows the parent's and loses everything in it.OrmModelextends it, and the list is derived rather than written out. That class declares no columns — columns belong to the concrete models extending it — so every instance property it or its traits declare is machinery by construction. A hand-written list would go stale the first time a trait gained a property, which is exactly the shape this failed in.- Memoised per class, because
getData()runs once per row of a list. A memo keyed on the wrong thing has its own test: one model answering with another's exclusions shows up as a missing column or an extra key, pointing nowhere near the cache.
The guard was right and looked one layer up¶
testEveryDeclaredBasePropertyIsExcluded() already derived its list from the class rather
than trusting a hand-maintained one — written after that list turned out to be missing two
entries. It walked Model and Framework\Base. It had never heard of OrmModel.
So the test that exists to stop exactly this kind of omission was itself scoped to the
layers somebody thought of. It now walks every layer that declares no columns, and there is
a second test asserting the payload rather than the list: a list that is right while
getData() reads a different one is the bug that was actually there.
Documentation¶
- ORM Guide — "
getData()answers columns, and only columns": how to exclude a model's own switch, why the list is derived, and why filtering out beats allowlisting in for these legacy-compatible models.
Http\Client handed back a compressed body it had not asked for and never decoded¶
https://bleedingcool.com/feed/ answers Content-Encoding: gzip to a request that sent no
Accept-Encoding, and ->body() was the compressed bytes.
cURL only inflates when CURLOPT_ENCODING is set. Without it the client sends no
Accept-Encoding and does not inflate, so a server that compresses anyway — CDNs and
caching plugins, which is to say most of the web — is handed straight through.
The failure points at the wrong end. The feed reader recorded "the answer was not a feed we could read", which is true and is about the parser. Nothing logs a decoding failure, because no decoding was attempted; the only way to the real cause is to look at the bytes.
Fixed¶
CURLOPT_ENCODING => ''— cURL's "every encoding I was built with". It advertises them and inflates the answer before anybody sees it.- The ceiling counts the decoded size, and that is asserted rather than assumed: cURL
inflates before calling the write function, so
maxResponseBytes()sees what comes out. That is the size that matters for memory — a ceiling counting the wire size would let a few compressed kilobytes expand into gigabytes before noticing. - Both tests run against a real socket, because the claim is about a cURL option. A faked response would have proved the test's own gzip round-trip and nothing else.
Documentation¶
- HTTP Client Guide — "compressed answers": that there is nothing to turn on, why the alternative fails where it does not belong, and which size the ceiling counts.