← Dev log

The Responder that had nothing to do

TetherPHP exists because I wanted the request pipeline to be traceable by reading it:

Request → Route → Action → Domain → Responder → Response

Six stages, each with one job. The Responder's job is to stand between what the application computed and how it is presented, so that changing a template never reaches back into business logic.

Here is what the Responder in the skeleton actually looked like until this week.

class Home extends Responder
{
    public function __invoke(array $data = []): Response
    {
        return $this->view('pages.home.index', $data);
    }
}

It takes an array. It passes the array along. It returns.

The array was the problem

Domain::handle() returned array<string, mixed>. The Responder handed that array to view(), which renders a template like this:

extract($__data, EXTR_SKIP);

ob_start();
include $__file;

extract() turns every key in the array into a variable in the template's scope. So the keys of the array a Domain returned were the names of the variables in the view. My home page reads $appName and $tagline, which meant Domains\Home looked like this:

public function handle(): array
{
    return [
        'appName' => env('APP_NAME'),
        'tagline' => 'An application built with TetherPHP.',
    ];
}

Rename $tagline to $strapline in an HTML template and you have to go and edit a business-logic class. That is the exact coupling the Responder sits in the middle to absorb — and it could not absorb it, because it had nothing to absorb it with. It received the finished shape and forwarded it.

An ADR framework whose R was a pass-through. The pattern was doing less work than the diagram suggested.

Where it actually hurt

The skeleton's home page is two strings, so the damage there is theoretical. This website is a real application, and its dev log is where the theory turned into something ugly.

A dev log request has three outcomes: the index, a post, or a slug that matches no file. All three travelled in one array. The Domain set a found key:

if ($path === null) {
    return ['found' => false, 'slug' => $slug];
}

and the Action bolted a second boolean on with array union, on its way past:

return $this->respond($data + ['single' => $slug !== null]);

so that the Responder could work out what it had been given:

if (!$data['single']) {
    return $this->view('pages.devlog.index', $data);
}

if (!$data['found']) {
    return $this->view('pages.devlog.notFound', $data, 404);
}

Two booleans, set in two different layers, reconstructing a fact that was known for certain at the top of the Action. Nothing in the type system had any idea. If single had been missing the failure would have been an undefined array key inside a Responder, and if a key the template wanted had been missing the failure would have been an undefined variable inside a template — the two worst places to find out.

Typed results

Domain::handle() now returns a DomainResult. The interface is in the framework and it is empty:

interface DomainResult
{
}

An empty marker interface looks like nothing, and I went back and forth on whether it earns its place. It does one job: it gives Domain::handle() and Action::respond() a type that is neither array nor object, so the pipeline still states what flows through it. That is the whole justification. If it needed methods, the framework would be making decisions about your domain, which it has no business doing.

Each feature owns its result. The Domain names the properties in its own terms:

final readonly class Home implements DomainResult
{
    public function __construct(
        public string $name,
        public string $description,
    ) {
    }
}

and the Responder — finally with something to do — decides what the template is allowed to call them:

public function __invoke(HomeResult $result): Response
{
    return $this->view('pages.home.index', [
        'appName' => $result->name,
        'tagline' => $result->description,
    ]);
}

The view did not change. That is the point: the naming moved, the rendering did not.

One result type per outcome

The second thing this buys is the one I did not expect to care about as much as I do. Both booleans are gone, because an outcome is now a type:

public function __invoke(DevLogIndex|DevLogPost|DevLogPostNotFound $result): Response
{
    return match (true) {
        $result instanceof DevLogIndex => $this->view('pages.devlog.index', [
            'posts' => $result->posts,
        ]),
        $result instanceof DevLogPostNotFound => $this->view('pages.devlog.notFound', [
            'slug' => $result->slug,
        ], 404),
        default => $this->view('pages.devlog.post', [
            'title' => $result->title,
            'body' => $result->body,
            // ...
        ]),
    };
}

The Action no longer merges anything into anything. It asks the Domain a question and hands the answer over:

$result = $slug === null
    ? $this->domain->handle()
    : $this->domain->post($slug);

return $this->respond($result);

The status code comes off the same decision as the view, in one place, from a type — rather than off a boolean that one layer set and another layer read.

What I decided not to do

The canonical ADR write-ups often give the Domain a Payload object carrying a status enum: Found, NotFound, Invalid, with the data hanging off it. I built the case for it and dropped it. One result type per outcome does the same work with no framework machinery at all, and a status enum is a fixed vocabulary that will be wrong for somebody's domain by the third application. Small & Composable and One Obvious Way both pointed the same direction, so the framework ships a marker interface and gets out of the way.

I also stopped short of turning every collection inside a result into a value object. DevLogIndex::$posts is still a list of arrays with a documented shape, as is the docs navigation. The coupling this change set out to kill is dead either way — a Domain no longer names anything for a template — and converting those means rewriting the templates that iterate them. Worth doing. Not done, and I would rather say so than let it look finished.

Shipped

Core v0.6.0, with the interface, the regenerated stubs, and make:feature writing the result class before the domain that names it in its return type. composer check is green at 85 tests and PHPStan level 8; the skeleton and this website are both migrated, verified against v0.6.0 resolved from Packagist rather than a local symlink, with every route serving the same content it did before — including both of the 404s that used to be a boolean.

It is a breaking change, and the honest summary of it is that the pattern now does what the diagram always claimed.