← Dev log

A kernel that could not be tested

TetherPHP documents one path through a request:

Request → Route → Action → Domain → Responder → Response

Until this week the last stage did not exist. There was no Response anywhere in the codebase. Kernel::run() returned a string, and the string was whatever the previous stage happened to hand back.

That sounds like a naming quibble. It was not. It was why the kernel had no tests.

Three exits from one method

run() could leave in three different ways.

A matched route returned the action's string. A view route started an output buffer, included a file, and returned the buffer. And an error set a status code, included an error view — printing it straight to the output, not returning it — and returned an empty string.

if (!isset($route->action)) {
    http_response_code(404);
    include(views_dir() . 'errors/404.php');
    return '';
}

So the method's contract was "returns the response body", except on the paths where it prints the body and returns nothing. The error handler was worse: it rendered and called exit().

You cannot write a test around that. exit() ends the PHP process, and the process running the assertion is the same process. There is no return value to inspect, no status to read back, nothing left to assert on. The most important method in the framework had zero coverage, and not through neglect — it was structurally untestable.

Giving the pipeline its last stage

Response is an immutable value: body, status, headers.

final class Response
{
    public function __construct(
        private readonly string $body = '',
        private readonly int $status = 200,
        private readonly array $headers = [],
    ) {}

    public static function html(string $body, int $status = 200): self;
    public static function json(array $data, int $status = 200): self;
    public static function redirect(string $location, int $status = 302): self;

    public function send(): void;
}

send() is the only place in the entire framework that writes to the client. public/index.php is now three meaningful lines:

$router = new Router();
(require __DIR__ . '/../routes/web.php')($router);

new Kernel($router)->run()->send();

Kernel::run() returns a Response on every path. A match, a miss, a rejected write, a route pointing at a class that does not exist — all of them are values now, not side effects.

What that immediately bought

Nine kernel tests, written the same afternoon, none of which were expressible before:

public function testAnUnmatchedRouteReturnsA404Response(): void
{
    $response = $this->get('/nothing-here');

    $this->assertSame(404, $response->status());
    $this->assertStringContainsString('404', $response->body());
}

That is the entire argument for the refactor. Not elegance — the ability to ask "what does this framework do with an OPTIONS request" and get an answer from a test rather than from curl.

Coverage went from 51 tests to 80.

The things that fell out on the way

Once responses were values, a few problems became obvious that had been invisible as side effects.

ActionInterface existed and bound nothing. The kernel checked is_callable($action) — so any object with __invoke qualified, and a class without one got as far as being called before failing. It now checks the instance and returns a logged 500.

Route parameters had been captured by the router and dropped by the kernel, so /docs/{page} never delivered page to anything. They arrive on the request now. My own site's actions had been re-parsing the URL by hand to recover what the router already knew, and both of them lost that code.

The router grew put(), patch() and delete(). It had only ever been able to register GET and POST, while the request class dutifully enforced CSRF on verbs that could not be routed to.

And RouteDTO is gone. It signalled "no match" by leaving a typed property uninitialised so callers could test it with isset(). Absence as control flow — invisible to a reader, invisible to static analysis. Route says whether it matched.

What I would take from this

The bug was not exit(). The bug was that the pipeline had a stage in the documentation with nothing behind it in the code, and the gap filled up with side effects — a status set here, a file included there, a process terminated somewhere else.

Naming the missing thing and making it a value fixed the testing problem, the status-code problem and the enforcement problem at once, because they were all the same problem wearing different clothes.