Dependency Injection in Depth: Containers, Autowiring, and Lifetimes

Go beyond basic constructor injection to understand setter and interface injection, the dependency inversion principle, how service containers autowire dependencies via reflection, service lifetimes (singleton/transient/scoped), circular dependencies, the composition root pattern, and build a minimal framework-independent DI container from scratch.

Dependency injection at its simplest is just 'pass collaborators in instead of constructing them inside the class' — but the reason entire frameworks are built around it is what that simple rule unlocks: classes that depend on interfaces rather than concrete implementations, code that's trivially testable because you can inject a fake, and a single place (a container) that knows how to assemble the whole object graph so nothing else in the codebase has to. This lesson goes past 'pass a logger into the constructor' into how containers actually resolve dependency graphs automatically, what can go wrong (circular dependencies, wrong lifetimes), and builds a small container from first principles so the mechanism isn't a black box.

Three injection styles, and why constructor injection wins by default

Constructor injection passes dependencies as constructor arguments, making them required and immutable for the object's lifetime — public function __construct(private LoggerInterface $logger) {}. This is the default choice for most dependencies because it makes 'this object cannot exist in a valid state without a logger' explicit and enforced by the language itself: there is no code path that produces a half-constructed object missing a required collaborator.

Setter injection passes a dependency via a mutator method after construction — public function setLogger(LoggerInterface $logger): void. This is appropriate specifically for genuinely optional dependencies (a class that logs if a logger was provided, and silently no-ops otherwise) — using it for a required dependency just reintroduces the bug class constructor injection exists to prevent: an object can exist in an invalid, half-configured state if the setter is never called.

Interface injection has the dependency itself define an interface the consumer must implement to receive it (e.g. an AwareInterface pattern like Symfony's LoggerAwareInterface with a setLogger() method the container calls automatically when it detects the interface) — it's the least common of the three in modern PHP, mostly seen in older frameworks or specific container-integration patterns, because constructor injection combined with a capable autowiring container covers the same ground with less ceremony.

Dependency inversion in depth

The Dependency Inversion Principle (the 'D' in SOLID) states two things: high-level modules should not depend on low-level modules, both should depend on abstractions; and abstractions should not depend on details, details should depend on abstractions. In practice for PHP: an OrderService (high-level policy: how to process an order) should type-hint a PaymentGatewayInterface, not a concrete StripeGateway class (low-level detail: how Stripe's API works). The interface belongs conceptually to the high-level module's needs, not to the low-level implementation — PaymentGatewayInterface is defined by what OrderService needs to call, and StripeGateway implements PaymentGatewayInterface adapts Stripe's actual API to that need, not the other way around.

This is what 'inversion' refers to: without it, the natural dependency arrow points from business logic down to the concrete Stripe SDK; with it, both the business logic and the Stripe adapter point inward at an interface that the business logic's needs defined, so the business logic no longer knows Stripe exists at all, and swapping to a different payment provider means writing a new adapter class with zero changes to OrderService.

Service containers and autowiring

A service container is an object responsible for constructing and supplying other objects, so the rest of the application never calls new on its own dependencies. Autowiring is the container's ability to look at a class's constructor via PHP's Reflection API — new \ReflectionClass($class), then ->getConstructor()->getParameters() — read each parameter's declared type, and resolve it recursively: if the parameter type-hints an interface, look up which concrete class was registered against that interface; if it type-hints a concrete class with its own constructor dependencies, resolve those first, recursively, before instantiating it. This is why constructor type-hints aren't just documentation in a DI-heavy codebase — they're the literal data the container reads to know what to build.

A service definition is the container's registration of how to build or locate a given service — either an explicit factory closure ($container->set(PaymentGatewayInterface::class, fn() => new StripeGateway($apiKey))), an interface-to-class binding for autowiring to follow ($container->bind(PaymentGatewayInterface::class, StripeGateway::class)), or, for classes with no interface indirection, nothing at all if autowiring can resolve the concrete class's own dependencies unaided.

Service lifetimes: singleton, transient, and scoped

A lifetime (or 'scope') controls how often the container creates a new instance versus reusing one. Singleton means the container builds the object once and returns the same instance for every subsequent request for that service — appropriate for stateless services (a logger, a database connection pool, a config object) where sharing one instance is both safe and avoids repeated construction cost. Transient means a brand-new instance is built every single time the service is requested — appropriate for anything with per-use mutable state that must never leak between callers. Scoped sits between the two: one shared instance per some enclosing unit of work — most commonly 'one instance per HTTP request' in a web framework, so within a single request the same instance is reused (useful for something like a 'current request' object or a per-request unit-of-work/transaction context), but a fresh instance is built for the next request.

Picking the wrong lifetime is a real, often subtle bug source: registering a database connection as transient wastes a new connection per use; registering a per-request 'current user' context as a singleton in a long-running worker process (not a fresh-process-per-request web server) can leak one request's user into another request's handling if the process outlives a single request — this exact mistake is a common source of data-leak bugs when moving code from traditional PHP-FPM (fresh process per request, so singleton-per-process is effectively scoped-per-request) to a long-running runtime like Swoole or RoadRunner, where a naive singleton now spans many requests.

Circular dependencies

A circular dependency exists when resolving service A requires service B, and resolving B requires A — directly, or through a longer chain (A needs B needs C needs A). A container performing recursive autowiring detects this the same way any recursive algorithm detects infinite recursion: by tracking which services are currently 'in progress' of being resolved on the current call stack, and throwing an explicit error the moment a service being resolved appears again in that in-progress set, rather than recursing until a stack overflow.

The fix is never 'make the container smarter' — a genuine circular dependency is a design smell indicating two classes are too tightly coupled. Common resolutions: introduce a third interface that both depend on instead of depending on each other directly (breaks the cycle by inverting one edge); use lazy resolution, where one side receives a closure or a proxy that resolves the other lazily on first use rather than eagerly at construction (defers the cycle past construction time, though it doesn't remove the underlying coupling); or, most often correct, refactor — the shared responsibility causing the mutual need for each other usually belongs in a third, separate service both of the original two depend on one-directionally.

The composition root and testability

The composition root is the single place in an application — ideally exactly one place, as close to the entry point (index.php, a CLI bootstrap) as possible — where the container is configured (all service definitions registered) and where the very first, top-level object is resolved from it. Everything below the composition root receives its dependencies via injection and never touches the container directly; reaching into the container from deep inside business logic (the 'service locator' anti-pattern — calling $container->get(SomeService::class) from inside a method rather than receiving it via the constructor) reintroduces the exact hidden-coupling problem DI exists to solve, because now that class secretly depends on the container itself rather than declaring its real dependencies.

This structure is precisely what makes unit testing straightforward: because every class receives its dependencies as constructor arguments typed to interfaces, a test can construct the class under test directly with hand-built fakes or mocks satisfying those interfaces — no container, no framework bootstrap, no database — and verify behavior in isolation. A class that reaches into a container or does its own new on a concrete dependency internally cannot be tested this way; the dependency has to be faked at whatever boundary it was actually obtained from, which is exactly why 'no new inside business logic classes for anything with a dependency chain' is treated as close to a hard rule in DI-based codebases.

Autowiring via Reflection — the core mechanism

ReflectionNamedType::isBuiltin() filters out scalar type hints (string, int, array) that the container has no way to guess a value for — those need an explicit default or an explicit factory binding. This snippet resolves recursively but has no cycle detection yet — the worked container example below adds that.

$reflection = new \ReflectionClass($className);
$constructor = $reflection->getConstructor();

if ($constructor === null) {
    return new $className(); // no dependencies to resolve
}

$args = [];
foreach ($constructor->getParameters() as $param) {
    $type = $param->getType();
    if ($type instanceof \ReflectionNamedType && !$type->isBuiltin()) {
        $args[] = $this->resolve($type->getName()); // recursive resolution
    } elseif ($param->isDefaultValueAvailable()) {
        $args[] = $param->getDefaultValue();
    } else {
        throw new \RuntimeException("Cannot resolve parameter \${$param->getName()}");
    }
}

return $reflection->newInstanceArgs($args);

Code Examples

get() tracks in-progress resolutions in $this->resolving; if a service ID reappears while it's already in that set, a circular dependency is thrown immediately with the full chain rather than recursing infinitely. autowire() uses Reflection to read OrderService's constructor, sees it needs PaymentGatewayInterface, and calls get() on that — which finds the explicit factory binding registered at the composition root and uses it instead of trying (and failing) to autowire an interface directly. Interfaces can never be autowired directly (you can't new an interface) — this is exactly why the composition root registers an explicit factory binding for PaymentGatewayInterface, while OrderService itself needs no binding at all because autowiring can fully resolve its one concrete, reflectable dependency.

if (isset($this->resolving[$id])) { throw new ContainerException("Circular dependency detected: {$chain}"); }

<?php

declare(strict_types=1);

final class ContainerException extends \RuntimeException {}

final class Container
{
    /** @var array<string, callable> explicit factory bindings */
    private array $factories = [];

    /** @var array<string, object> resolved singleton instances */
    private array $singletons = [];

    /** @var array<string, true> services currently being resolved, for cycle detection */
    private array $resolving = [];

    public function bind(string $id, callable $factory): void
    {
        $this->factories[$id] = $factory;
    }

    public function get(string $id): object
    {
        if (isset($this->singletons[$id])) {
            return $this->singletons[$id];
        }

        if (isset($this->resolving[$id])) {
            $chain = implode(' -> ', [...array_keys($this->resolving), $id]);
            throw new ContainerException("Circular dependency detected: {$chain}");
        }

        $this->resolving[$id] = true;

        try {
            $instance = isset($this->factories[$id])
                ? ($this->factories[$id])($this)
                : $this->autowire($id);
        } finally {
            unset($this->resolving[$id]);
        }
…

Resolution path: Controller through the container to a concrete implementation

OrderController never mentions StripeGateway anywhere in its own code — the container is the only place that binding exists.

OrderController
        |
        |  constructor asks for: PaymentGatewayInterface
        v
  PaymentGatewayInterface  <-- abstraction; OrderController knows only this
        ^
        |  container's binding says: interface -> StripeGateway
        |
  ServiceContainer
        |
        |  reflects StripeGateway's constructor, resolves ITS dependencies
        |  (e.g. HttpClientInterface -> GuzzleAdapter), THEN instantiates it
        v
  StripeGateway  implements  PaymentGatewayInterface
        |
        v
  (talks to Stripe's real HTTP API)

Why frameworks ship a container instead of leaving DI manual

Laravel's container, Symfony's DependencyInjection component, and PHP-DI all exist because manually wiring a real application's object graph by hand — dozens of services, several layers of interface indirection — becomes unmanageable past a small app. Autowiring plus a small number of explicit bindings (for interfaces, which can never be autowired directly, and for anything needing config values) is the practical middle ground: most classes need zero container configuration at all, and only the genuine seams (interface-to-implementation choices, external API keys) need an explicit line in the composition root.

class OrderService { public function checkout(int $cents): bool { $gateway = new StripeGateway($_ENV['STRIPE_KEY']); return $gateway->charge($cents); } } class OrderService { public function __construct(private readonly PaymentGatewayInterface $gateway) {} public function checkout(int $cents): bool { return $this->gateway->charge($cents); } }

Constructing StripeGateway directly inside OrderService hard-couples business logic to one concrete payment provider and to reading environment variables, making OrderService impossible to unit test without a real (or mocked-via-monkeypatching) Stripe call; injecting PaymentGatewayInterface lets a test pass in a trivial fake and lets the composition root swap providers without touching OrderService at all.

Why can a DI container never autowire an interface type-hint directly, without an explicit binding? A. Interfaces are slower to resolve than classes B. PHP does not support reflecting on interfaces C. An interface has no constructor and cannot be instantiated with new — the container needs to be told which concrete class implements it D. Interfaces can only be used with setter injection

C. An interface has no constructor and cannot be instantiated with new — the container needs to be told which concrete class implements it — You cannot instantiate an interface — new SomeInterface() is a fatal error — so the container has no way to guess which concrete implementation to build unless an explicit binding (interface-to-class, or a factory) tells it.

What is the defining risk of registering a service as a singleton on a long-running runtime (e.g. Swoole/RoadRunner) when it was designed assuming a fresh process per request? A. The service will use more memory than a transient service B. State from one request can leak into another request's handling, since the same instance now persists across many requests instead of being scoped to one C. Singleton services cannot be autowired D. The container will throw a circular dependency error

B. State from one request can leak into another request's handling, since the same instance now persists across many requests instead of being scoped to one — On a fresh-process-per-request server (PHP-FPM), a process-wide singleton is effectively already scoped to one request. On a long-running runtime where one process serves many requests, that same singleton now persists across all of them, so any per-request mutable state stored on it can leak between unrelated requests.

How does a container that resolves dependencies recursively typically detect a circular dependency? A. It cannot detect them; a circular dependency always causes a stack overflow B. It tracks which service IDs are currently mid-resolution on the call stack and throws immediately if one is requested again while already in that set C. It counts the total number of services and rejects the graph if it exceeds a limit D. PHP's type system rejects circular type hints at compile time

B. It tracks which service IDs are currently mid-resolution on the call stack and throws immediately if one is requested again while already in that set — By marking each service ID as 'in progress' before recursing into its dependencies and checking that marker on every resolution request, the container can detect a cycle (A needs B needs A) the instant it reappears, rather than recursing until a stack overflow.

Using the worked Container example, add a NotificationService that depends on PaymentGatewayInterface AND a new LoggerInterface (bound to a simple EchoLogger that print()s messages). Register the bindings at the composition root and resolve NotificationService — confirm it autowires correctly without any changes to the Container class itself. Hint: LoggerInterface needs its own explicit bind() call just like PaymentGatewayInterface did, since interfaces can never be autowired directly — only concrete classes can.

Try it yourself — there's no single right answer here; compare your result against this lesson's examples.