DEV Community

Cover image for PSR-11 Containers and the Composition Root: One Interface, Any Framework
Gabriel Anhaia
Gabriel Anhaia

Posted on

PSR-11 Containers and the Composition Root: One Interface, Any Framework


You open a use case file, scroll to the constructor, and find this:

public function __construct(
    private ContainerInterface $container,
) {}

public function execute(PlaceOrderInput $in): void
{
    $repo = $this->container->get(OrderRepository::class);
    $gateway = $this->container->get(PaymentGateway::class);
    // ...
}
Enter fullscreen mode Exit fullscreen mode

The class asks for a container and pulls its own dependencies out of it. It compiles. It runs. And it has just made the container a hidden dependency of your domain. Nothing in that signature tells you the use case needs an OrderRepository and a PaymentGateway. You find out by reading the body, or by watching it throw at runtime when a binding is missing.

PSR-11 is a good standard. The mistake is where you touch it. The interface belongs at the edge of the application, in one file, called once per request. Everywhere else, dependencies arrive through constructors. Drawing that line is the whole job.

What PSR-11 actually is

PSR-11 is small. It defines Psr\Container\ContainerInterface with two methods and two exception interfaces.

<?php

namespace Psr\Container;

interface ContainerInterface
{
    /** @return mixed */
    public function get(string $id);
    public function has(string $id): bool;
}
Enter fullscreen mode Exit fullscreen mode

That is the whole read side. get($id) returns an entry or throws. has($id) reports whether an entry exists. There is no set, no bind, no register. PSR-11 standardizes how you read from a container, not how you fill one. Configuration is left to each implementation, which is why PHP-DI, Symfony's container, League Container, and Laravel's container all expose different builder APIs behind the same read interface.

The two exception interfaces matter more than they look:

<?php

namespace Psr\Container;

use Throwable;

interface ContainerExceptionInterface extends Throwable {}

interface NotFoundExceptionInterface extends ContainerExceptionInterface {}
Enter fullscreen mode Exit fullscreen mode

NotFoundExceptionInterface is thrown when get() is called with an id the container does not know. ContainerExceptionInterface covers any other failure building an entry. Catch the specific one when a missing id is a normal condition; let the general one bubble when a build fails.

Two ways to consume a container

There are exactly two patterns, and they are not equal.

The first is the service locator: a class receives the container and asks it for whatever it needs, when it needs it. That is the constructor at the top of this post. The dependencies are invisible in the type, resolved lazily, and unit-testing the class means building or mocking a container.

The second is dependency injection: a class declares its dependencies as constructor parameters, and something else assembles them. The container never enters the class.

<?php

declare(strict_types=1);

namespace App\Application\Order;

use App\Application\Port\OrderRepository;
use App\Application\Port\PaymentGateway;

final readonly class PlaceOrder
{
    public function __construct(
        private OrderRepository $orders,
        private PaymentGateway $payments,
    ) {}

    public function execute(PlaceOrderInput $in): PlaceOrderOutput
    {
        // uses $this->orders and $this->payments
    }
}
Enter fullscreen mode Exit fullscreen mode

Read that constructor and you know exactly what the use case depends on. Two ports, both named in domain language, neither of them a container. To test it, you pass two fakes. There is no ContainerInterface import anywhere in Application/ or Domain/.

The container still exists. It just does not reach inside this class. It reaches the boundary of the application and stops there.

The composition root

The composition root is the single place where you build the object graph. One file, run once, as close to the entry point as you can put it. public/index.php for HTTP, bin/console for CLI, the worker bootstrap for a queue consumer. It is the only code that names concrete adapters, and the only code that calls $container->get(...).

Here is a composition root built on PHP-DI, wiring ports to adapters:

<?php

declare(strict_types=1);

use App\Application\Port\OrderRepository;
use App\Application\Port\PaymentGateway;
use App\Application\Order\PlaceOrder;
use App\Infrastructure\Payment\HttpPaymentGateway;
use App\Infrastructure\Persistence\DoctrineOrderRepository;
use DI\ContainerBuilder;
use Psr\Container\ContainerInterface;

use function DI\autowire;
use function DI\get;

$builder = new ContainerBuilder();
$builder->addDefinitions([
    OrderRepository::class => autowire(DoctrineOrderRepository::class),
    PaymentGateway::class => autowire(HttpPaymentGateway::class)
        ->constructorParameter('endpoint', get('payment.url')),
    'payment.url' => $_ENV['PAYMENT_GATEWAY_URL'],
    PlaceOrder::class => autowire(),
]);

$container = $builder->build();
Enter fullscreen mode Exit fullscreen mode

Every binding maps an interface to a concrete class. PlaceOrder::class => autowire() tells the container to read the use case's constructor, see it wants an OrderRepository and a PaymentGateway, and supply the adapters bound above. The use case never learns any of this happened.

The composition root is allowed to know everything. It imports from Infrastructure/, it reads environment variables, it names the payment endpoint. That is its job. The trade you are making is concentration: all the framework-specific, environment-specific, wiring-specific knowledge lives in one file per entry point, instead of leaking into two hundred classes.

The one place get() is fair

At the very edge, something has to pull the first object out of the container. A front controller resolves the router, the router maps a URL to a handler, and the handler needs to come from somewhere. That first get() is legitimate:

<?php

declare(strict_types=1);

// public/index.php

require __DIR__ . '/../vendor/autoload.php';

$container = require __DIR__ . '/../src/Bootstrap/container.php';

$request = ServerRequestFactory::fromGlobals();
$handlerId = $router->match($request);

// The single get() call for this request.
$handler = $container->get($handlerId);

$response = $handler->handle($request);
(new SapiEmitter())->emit($response);
Enter fullscreen mode Exit fullscreen mode

One get() per request, at the entry point, resolving the top of the graph. Everything below $handler was constructor-injected while the container built it. The controller got its use case, the use case got its ports, the adapters got their clients, all without a single class asking the container for anything.

Most modern frameworks hide this call inside their kernel. Symfony's HttpKernel, Laravel's HTTP kernel (Illuminate\Foundation\Http\Kernel::handle), Mezzio's RequestHandlerRunner all own the boundary get() so you never write it. When you build a small service by hand, you write it once, in index.php, and never again.

Keeping the domain container-free

Draw the line with a check that runs in CI. The Domain/ and Application/ directories should never import Psr\Container. If they do, a service locator has crept back in.

#!/usr/bin/env bash
set -euo pipefail

if grep -rn "Psr\\\\Container" src/Domain src/Application; then
    echo "PSR-11 leaked into the domain. Inject dependencies instead."
    exit 1
fi

echo "Domain and application are container-free."
Enter fullscreen mode Exit fullscreen mode

Static analysis does the same with more precision. Deptrac and PHPArkitect both let you forbid a layer from depending on a namespace, and a rule that bans Psr\Container inside App\Domain and App\Application fails the build the moment someone reaches for the shortcut.

The check is worth having because the shortcut is tempting under deadline. Injecting the container "just this once" to grab one more service feels faster than adding a constructor parameter and updating the binding. It is faster, by about ninety seconds, and it costs you the next engineer who has to guess what the class actually needs.

Why the boundary holds up

The payoff is the same as every hexagonal move: the concern lives in one place, so it changes in one place.

Swap PHP-DI for Symfony's container and you rewrite one composition root per entry point. The use cases do not change, because they never named PHP-DI. Move a service from the HTTP path to a queue worker and you add a binding to the worker's root; the use case is identical in both. Write a test and you skip the container entirely, constructing the use case with fakes, because the class only ever asked for its ports.

The container is an adapter for object construction. Treat it like every other adapter: it plugs into the edge, it does its job, and the domain never learns its name. PSR-11 gives you one interface to read from any of them, which is exactly why you only need to touch that interface once.

Keeping construction at the edge is the same discipline as keeping persistence, HTTP, and messaging at the edge. When the wiring lives in a composition root and the domain speaks only in ports, the framework becomes a detail you can replace, and that replaceability is the whole argument of Decoupled PHP — an application built to outlive the tools it happens to run on today.

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)