Services

How a Domain gets a database connection

A Domain is where your logic lives, and it usually needs something to do its job — a database connection, a mailer, a clock. But you never construct a Domain yourself. The Action does, and you never construct the Action either. The Kernel does, and for a long time the only thing it handed the Action was the Request.

So there was no way to get a connection from the outside world into a Domain. What people do when a chain is closed like that is reach for a global — a db() beside env() — and then every class secretly depends on things its signature never mentions. TetherPHP's answer is not a container. It is one ordinary object.

One class says what the application is made of

Every application has an app/Services.php. It is a plain final readonly class with one public property per thing the application has:

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

Env and Log are the two properties ServicesInterface asks for, because the Kernel itself runs on them. Everything after that is yours. A fresh project ships with just those two.

One file builds it

You construct it by hand in public/index.php, and it is the only thing the Kernel is handed besides the router and the middleware:

$env = Env::fromFile(__DIR__ . '/../.env');

$services = new Services(
    env: $env,
    log: new Log(__DIR__ . '/../storage/logs'),
    db: new PDO($env->get('DB_DSN', 'sqlite:' . __DIR__ . '/../storage/app.sqlite')),
);

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

Which .env is in play, where the logs go, which database is connected: all answered by reading one file, in the order it is written. Nothing is looked up by name and nothing is resolved by type hint.

Every Action is handed it

The Kernel constructs each Action with the Request and the services. The Action passes its Domain the pieces the Domain asks for:

// app/Actions/Post/Store.php
public function __construct(protected Request $request, Services $services)
{
    $this->domain = new StoreDomain($services->db, $request->payload);
    $this->responder = new StoreResponder($request);
}

// app/Domains/Post/Store.php
public function __construct(private PDO $db, private array $payload)
{
}

The generators write the Action this way, so the place a dependency comes from is in every file tether make:feature produces.

A Domain takes what it needs, never the whole object

This is the rule that makes the rest worth having. Domains\Post\Store asks for a PDO, not for Services. Its constructor is then the complete, honest list of what it depends on — and in a test you hand it an in-memory SQLite instead of the real thing:

$domain = new StoreDomain(new PDO('sqlite::memory:'), ['title' => 'Hello']);

No globals to install, no mocking framework, no container to boot. A Domain that took Services would depend on everything and say nothing.

Adding one

Two lines. A property on Services, and its construction in public/index.php:

public Mailer $mailer,                         // app/Services.php
mailer: new Mailer($env->get('SMTP_DSN')),     // public/index.php

Everything in Services is built on every request, in the order written. If something is expensive to build and most requests never touch it, make that thing connect on first use — the way Session does — rather than making the wiring lazy.

tether inspect App\Services lists what it provides, and tether context carries it under services, so an agent reading the output knows there is a database to ask for and what it is called. Neither constructs it.