← Dev log

The Domain that read its settings from the air

The skeleton application has one Domain. It builds the home page. Here is its unit test as it stood until today:

class HomeDomainTest extends TestCase
{
    protected function setUp(): void
    {
        // env() delegates to whatever was installed at boot; a unit test
        // installs its own rather than reading the .env on disk
        Env::use(new Env(['APP_NAME' => 'Test App']));
    }

    public function testItNamesTheApplicationFromTheEnvironment(): void
    {
        $this->assertSame('Test App', new HomeDomain()->handle()->name);
    }
}

The docblock above that class says a Domain "knows nothing about HTTP, so testing one needs no Kernel, no routing and no request". True. But look at setUp(). Before the test can construct the Domain it has to install a global, because the Domain reads env('APP_NAME') out of the air, and there is nothing in new HomeDomain() that tells you so.

That is a small thing. The reason it was there is not small.

The chain was closed

In TetherPHP you do not construct a Domain. The Action does. And you do not construct the Action either — the Kernel does, and it handed the Action exactly one thing:

$action = new $route->action($this->request);

So anything a Domain needed that was not on the Request — a database connection, a mailer, a clock — had no way in. The chain from public/index.php to the class that runs the query was closed at the Kernel.

I had not noticed because the skeleton needs nothing. Its one Domain reads one setting, and env() covers that. But env() is a service locator with a two-word vocabulary, and the first application to open a database would have written db() to match, then mailer(), and then every class in it would depend on things its constructor never mentioned. The framework's principles say explicit over magic and constructor arguments beat container lookups, and the framework itself had left exactly one route open, and it was a global.

What I did not want to build

The known answer is a container: a registry that looks at your constructor's type hints and builds what they name. It is powerful. It is also the definition of the thing this framework exists to not have — behaviour you cannot follow by reading the code, resolved somewhere you did not write.

The other option I considered was a factory closure per route in routes/web.php:

$router->get('/', fn (Request $r) => new Actions\Home\Index($r, new PDO(...)));

Explicit, certainly. But tether routes, tether explain, tether inspect and tether context all work by reflecting on the Action's class name, and a closure hides it. The tooling would go blind on every route that used one. That was the end of that.

One ordinary object

What shipped is the least clever thing I could think of. The application declares one class that says what it is made of:

final readonly class Services implements ServicesInterface
{
    public function __construct(
        public Env $env,
        public Log $log,
    ) {
    }
}

public/index.php builds it, by hand, in the same file that already built the Env and the Log:

$services = new Services(
    env: Env::fromFile(__DIR__ . '/../.env'),
    log: new Log(__DIR__ . '/../storage/logs'),
);

new Kernel($router, $services, $middleware)->run()->send();

And the Kernel constructs every Action with it:

$action = new $route->action($this->request, $this->services);

That is the entire mechanism. Adding a database is a property on Services and a new PDO(...) in index.php. Nothing is looked up by name. Nothing is resolved by type hint. tether inspect App\Services lists what it holds and tether context carries it under services, and neither of them constructs it, because it is built in the one file the console must never load.

The rule that makes it worth having

The Action hands its Domain the pieces it needs. Never the whole object.

public function __construct(protected Request $request, Services $services)
{
    $this->domain = new IndexDomain($services->env);
    $this->responder = new IndexResponder($request);
}

Domains\Home\Index now takes an Env through its constructor. A Domain that took Services would depend on everything and say nothing — you would be back to reading the body to find out what it uses. A Domain that takes PDO $db has a constructor that is the complete, honest list, and a test hands it new PDO('sqlite::memory:').

Which brings the unit test back around:

private function domain(array $vars): HomeDomain
{
    return new HomeDomain(new Env($vars));
}

public function testItNamesTheApplicationFromTheEnvironment(): void
{
    $this->assertSame('Test App', $this->domain(['APP_NAME' => 'Test App'])->handle()->name);
}

No setUp(). No global. The class that knows nothing about HTTP now also depends on nothing it was not handed.

The version I threw away

The first cut kept the Kernel's signature and added services as a fifth argument:

new Kernel($router, $env, $log, $middleware, $services)

It worked, all the tests passed, and it was wrong. Services contained the Env and the Log — they are two of the things the application is made of — so index.php was handing the Kernel the same Env twice, once bare and once inside an object. Reading that file back, the obvious question was: does the Kernel still need those two if they are already in there?

It does not. ServicesInterface now declares the two properties the Kernel actually runs on — PHP 8.4 lets an interface declare properties, and a plain promoted public Env $env satisfies one — and the Kernel takes the services and nothing else:

interface ServicesInterface
{
    public Env $env { get; }

    public Log $log { get; }
}

That is a breaking change to the Kernel's constructor, and it is v0.11.0. The skeleton, this site and every future application build one Services and hand it over. The framework reads two properties off it and never looks at the rest, because past those two it has no business knowing what your application is made of.

What is still open

routes/middleware.php still takes (Env, Log) rather than the services. The console builds that list to report what wraps a request, and it cannot build your services — it has no idea what a new PDO(...) in your index.php would do from a terminal. So a middleware that needs a connection has no explicit route to one yet. I would rather write that down than pretend the seam is finished.