DEV Community

Cover image for The Composition Root: Where PHP Dependency Wiring Actually Belongs
Gabriel Anhaia
Gabriel Anhaia

Posted on

The Composition Root: Where PHP Dependency Wiring Actually Belongs


You grep the codebase for new StripeClient. Forty hits. Some in controllers, some in services, two in a console command, one in a Twig extension nobody remembers writing. Each one constructs the client with a slightly different config. The test suite stubs it in nineteen places. When the API key rotates, you find the leak the hard way: a background job that built its own client with the old key hardcoded.

Nobody decided this. It grew. Every time someone needed a Stripe call, they reached for new at the point of use, because that was the shortest path through code review that afternoon.

The fix is a single idea with a name: the composition root. One place in the application that knows every concrete class. Everywhere else asks for an interface and receives whatever the root decided to hand over.

What the composition root is

Mark Seemann coined the term in Dependency Injection in .NET: the composition root is the single location, as close to the application's entry point as you can get, where you compose the object graph. It is where abstractions get bound to implementations.

In a PHP service that means: the moment a request, a CLI invocation, or a queue message arrives, one piece of code builds the use case it needs, with every dependency already wired, and hands it the input. That building code is the only code allowed to say new DoctrineOrderRepository(...) or new HttpPaymentGateway(...).

The use case never constructs its collaborators. It declares them as constructor parameters typed against interfaces:

final readonly class PlaceOrder
{
    public function __construct(
        private CustomerRepository $customers,
        private OrderRepository $orders,
        private PaymentGateway $payments,
    ) {}
}
Enter fullscreen mode Exit fullscreen mode

PlaceOrder has no idea Doctrine exists. It cannot construct a Stripe client even if it wanted to, because it has never imported one. The knowledge of which concrete class fills each slot lives in exactly one file. Move the wiring there and the forty scattered new StripeClient calls collapse into one binding you can read in a breath.

Pure constructor wiring, no container

You do not need a DI container to have a composition root. The plainest version is a function that calls constructors in order. People call this "pure DI," and for a small service it reads better than any container config.

<?php

declare(strict_types=1);

function buildPlaceOrder(): PlaceOrder
{
    $pdo = new PDO((string) $_ENV['DATABASE_DSN']);
    $clock = new SystemClock();

    $customers = new DoctrineCustomerRepository($pdo);
    $orders = new DoctrineOrderRepository($pdo);
    $payments = new HttpPaymentGateway(
        new GuzzleHttp\Client(),
        (string) $_ENV['PAYMENT_GATEWAY_URL'],
    );

    return new PlaceOrder($customers, $orders, $payments);
}
Enter fullscreen mode Exit fullscreen mode

Read it top to bottom and the whole object graph is right there. No magic, no reflection, no annotations. When a new engineer asks "what talks to the database in this flow," you point at line one. When the payment URL changes, you change one line.

The cost is that you write the wiring by hand. For a service with a handful of use cases that cost is near zero and the clarity is worth it. The function lives in src/Bootstrap/, the front controller calls it, and the domain stays untouched.

When the framework container takes over

A real Symfony or Laravel app has hundreds of services, and writing every constructor call by hand stops being pleasant. This is the job the framework container does well. Autowiring inspects constructor type hints and resolves the graph for you.

The trap is letting the container leak into your code. A container is itself a dependency, and a class that pulls services out of it is hiding what it actually needs:

// Service Locator — the anti-pattern.
final class PlaceOrder
{
    public function __construct(private ContainerInterface $c) {}

    public function execute(PlaceOrderInput $in): void
    {
        $orders = $this->c->get(OrderRepository::class);
        $payments = $this->c->get('payment.gateway');
        // ...
    }
}
Enter fullscreen mode Exit fullscreen mode

Now PlaceOrder depends on the container, the string keys inside it, and the global config. You cannot read the class and know its dependencies. You cannot test it without booting a container. The type system tells you nothing.

The container belongs at the edge, not in the domain. In Symfony, autowiring resolves the graph at the boundary and injects finished objects into your controllers. Your PlaceOrder keeps its three typed constructor parameters and never imports ContainerInterface. The binding that says "this interface maps to that class" goes in services.yaml or a compiler pass:

services:
    App\Application\Port\OrderRepository:
        class: App\Infrastructure\Persistence\DoctrineOrderRepository

    App\Application\Port\PaymentGateway:
        class: App\Infrastructure\Payment\HttpPaymentGateway
        arguments:
            $endpoint: '%env(PAYMENT_GATEWAY_URL)%'
Enter fullscreen mode Exit fullscreen mode

That config is the composition root for a framework app. The framework container is an implementation of the same idea: one place that maps abstractions to concretes. Hand-rolled function or services.yaml, the principle holds — wiring lives in one layer, and that layer is not the domain.

Keep the binding file boring

Whichever form you pick, the composition root has one job: construct objects. It should not run business logic, open transactions, log, or branch on request data. The moment a binding starts deciding things, it has stopped being wiring and become a use case in disguise.

A useful tell: if you can read a binding file and predict every concrete class the app uses without opening anything else, the root is doing its job. If you find an if ($_ENV['MODE'] === 'legacy') with thirty lines of alternate construction underneath, that branch wants to be two named factories instead.

Cross-cutting behavior gets wired here too, by composition rather than inheritance. A transactional decorator is the clean example:

$placeOrder = new TransactionalDecorator(
    new PlaceOrder($customers, $orders, $payments),
    $pdo,
);
Enter fullscreen mode Exit fullscreen mode

TransactionalDecorator implements the same interface as PlaceOrder, opens a transaction before execute, commits on success, rolls back on a thrown exception. The use case never learns it is wrapped. Callers never learn either. The decision to wrap it is made once, in the root, where every wiring decision belongs.

The domain stays blind on purpose

The point of pushing wiring to one edge is what it does to everything else. The domain layer imports nothing from the framework, the container, or any vendor package. It declares interfaces in its own language (OrderRepository, PaymentGateway, Clock) and trusts that something, somewhere, will satisfy them.

That blindness is the asset. You can run the entire domain in a unit test with in-memory fakes, no container booted, no database, in milliseconds. You can swap Doctrine for raw PDO by editing one binding. You can move from Guzzle to the Symfony HTTP client and the only file that changes is the one that constructs the gateway.

When the framework's next major version reshapes how services are registered, the blast radius is the composition root and the adapters around it. The use cases, the entities, the value objects — the code that encodes what your business actually does — do not move. They never knew which framework they were running under, so the upgrade cannot reach them.

That is the whole trade. Concentrate the knowledge of concrete classes in one boring file, and the interesting files stop caring.


If this was useful

The composition root is one seam in a larger discipline: pushing every framework decision to the edge so the domain can outlive the framework. Decoupled PHP works through the full shape — ports, adapters, use cases, and how to retrofit this onto a Laravel or Symfony app that has been accreting for years — using the same vocabulary as this post. The goal is an application where the framework is an adapter you wire in one place, not the thing your business logic is built out of.

Decoupled PHP — Clean and Hexagonal Architecture for Applications That Outlive the Framework

Available on Kindle, Paperback, and Hardcover. English, German, and Japanese editions out now — Portuguese and Spanish coming soon.

Top comments (0)