← Dev log

The route that matched, ran, and found nothing

TetherPHP's router has accepted five verbs since v0.4.0:

$router->put('/posts/{id}', Actions\Post\Update::class);

That line registers. The route matches. The Kernel builds the Action, the Action builds its Domain, the Domain runs. And the payload is empty.

Not an error. Not a log line. Not a 500. The form fields are simply not there, and every layer between the browser and the domain reports success.

Why

PHP populates $_POST for a POST body and for nothing else. The Kernel read $_POST. So a form-encoded PUT, PATCH or DELETE — an update or a delete sent by anything that is not a JSON client — arrived as an empty array.

I had tested PUT routing. I had a test asserting a put() route matches and invokes its Action. It passed, because it does. The test never sent a body, because the fixture Action never read one.

That is the shape of defect I find hardest to catch: the feature works right up to the point where someone uses it for its purpose.

It was worse than one verb

Once I started pulling, the request object turned out to know about one and a half of the three places input arrives from.

The query string was parsed and thrown away. requestPath() split it off REQUEST_URI so that /posts?page=2 could match the route /posts — correct, and necessary — and then dropped it. There was no way to read it. An index page could not paginate, filter or search. My own roadmap had this written down as a known gap and I had walked past it for three releases.

Route parameters were being corrupted. Request lowercased the whole URI in a property hook, to make routing case-insensitive. It worked, and it destroyed the URL to do it: every parameter captured out of a lowercased URI is a lowercased parameter. /posts/My-First-Post gave you my-first-post. No slug with a capital in it, and half of every UUID, silently mangled.

Case-insensitive matching is a comparison, not a transformation. Router::match() compares case-insensitively now and captures segments verbatim.

The one piece of magic, and where I put it

A browser form sends GET or POST. That is the whole reason PUT and DELETE routes were unreachable from a page rather than merely broken.

The convention is a hidden field:

<form method="post" action="/posts/12">
    <input type="hidden" name="_method" value="PUT">

Which is magic. A field name changes what the request is. TetherPHP's third principle says if something matters, make it visible — and it does not say "never do this", it says make it visible.

So it is not in the Kernel. It is a middleware you compose:

return function (Env $env, Log $log): array {
    return [
        new OverridesMethod(),
        new VerifyCsrfToken(new Session(), $log),
    ];
};

Composed in, it is a line in a file whose entire job is listing what a request passes through, and tether routes and tether explain both print it. Left out, _method is an ordinary form field that means nothing. The seam I built in v0.9.0 for CSRF turned out to be exactly the right place to put the second thing that needed it, which is the first real evidence that the seam was worth building.

Then the website found the next bug

The site you are reading is a TetherPHP application. That is deliberate: it is the framework's dogfood, and it had been sitting three minor releases behind.

Migrating it took an hour. Within minutes of finishing, tether explain /devlog/hello told me the Result type behind this very page did not exist.

It does exist. Domains\DevLog\Show returns Post|PostNotFound — a union, because the section can end two ways and the Responder picks both the view and the status code off the type. That is a pattern the framework's own guides recommend.

The introspection command had just been taught to read the Result off the return type handle() declares, instead of guessing it from the Action's name. It read a single named type. A union is not a ReflectionNamedType, so it fell through to the guess, and the guess was wrong.

Worse, the fallback message said "no Domain was found to read a return type from" — which was untrue. The Domain was right there. The tool was confidently explaining a situation it had misread.

Fixed in v0.10.1, half an hour after v0.10.0 went out. I would not have found it this month otherwise, because core's own test suite has no ADR base classes to build that shape against. The dogfood is not a slogan; it is the only thing in the workflow that runs the framework the way an application does.

What I took from it

A framework can register a capability without supporting it, and nothing in the type system, the tests or the tooling will say a word. put() existed for six releases. Every test of it passed. It was decorative.

The tests I was missing were not unit tests of the router. They were the ones that send a real request with a real body and assert on what the Action actually received.