Service Containers: Bindings, Lifecycles, Autowiring, and Circular Dependencies

Understand how a dependency injection service container works internally in PHP: registering bindings, choosing singleton vs transient lifecycles, autowiring concrete classes via reflection, using factories for objects that need custom construction, binding interfaces to implementations, and detecting circular dependencies.

A service container is the object responsible for building other objects. Instead of every class manually constructing its own dependencies (and their dependencies, and so on), the container knows how to assemble the whole dependency graph on request — which is what lets bootstrap wire an application together in one place, as covered in the previous lesson.

Bindings and interface bindings

A binding tells the container what to build when something asks for a given type. The simplest binding maps an interface to a concrete implementation — for example, binding PaymentGateway (an interface) to StripePaymentGateway (a class) — so that any class that type-hints PaymentGateway in its constructor receives a StripePaymentGateway instance without knowing that class name at all.

This is the mechanism that makes swapping implementations painless: switching every consumer of PaymentGateway from Stripe to a different provider, or to a fake for tests, means changing one binding in the container's configuration, not editing every class that depends on payments.

Lifecycles: singleton vs transient

A lifecycle (or 'scope') decides how many instances of a bound type the container will ever create. A singleton binding builds the object once and returns the same instance on every subsequent request — appropriate for things like a database connection, a logger, or configuration, where sharing one instance is correct and creating many would be wasteful or even harmful (e.g. multiple competing DB connections).

A transient binding builds a brand-new instance every time it is requested — appropriate for lightweight, stateless objects, or objects that must not accidentally share mutable state between two unrelated parts of the request (for example, a fresh command object per request handler). Choosing the wrong lifecycle is a common source of subtle bugs: a singleton service that quietly accumulates per-request state will leak that state into the next, unrelated request that reuses it.

Autowiring via reflection

Autowiring is the container's ability to build a concrete class it has no explicit binding for, by inspecting its constructor and resolving each parameter automatically. PHP's Reflection API lets the container ask a class, at runtime, 'what are your constructor parameters, and what type is each one?' — the container then recursively resolves each typed parameter (which may itself trigger more autowiring, or hit an explicit binding) and calls the constructor with the results.

Autowiring only works for parameters with a concrete class or interface type hint (an interface hint requires an explicit interface binding, since the container cannot guess which implementation you mean); scalar parameters like a string or int without a container-known value still need an explicit binding or a default value.

Factories for custom construction

Sometimes building an object is not a matter of just calling its constructor with resolved dependencies — it might need a value read from configuration, a conditional choice between two constructors, or a multi-step setup process. A factory binding lets you register a closure (or a factory class) that the container calls to produce the instance, instead of relying on autowiring.

Factories are also how you handle objects the container should not construct directly at all, such as a third-party SDK client that requires an API key from configuration: the factory closure pulls the key out of the config service, constructs the SDK client with it, and returns it, and every other class that depends on that client just receives the finished object.

Circular dependencies

A circular dependency happens when resolving type A requires building type B, but building B requires an already-being-built A — for example, OrderService needs a NotificationService, and NotificationService (to log which order triggered a notification) needs an OrderService back. Constructor injection cannot satisfy this: to finish constructing A you need a finished B, but to finish constructing B you need a finished A, and neither can complete first.

A well-built container detects this at resolution time (usually by tracking the chain of types currently being resolved on the call stack and throwing a clear 'circular dependency' error the moment a type reappears in its own resolution chain) rather than looping forever or silently returning a broken partial object. The actual fix is architectural, not something the container can paper over: break the cycle by extracting the shared behavior both classes need into a third class they both depend on, or by injecting a narrower interface, or by switching one side to lazy resolution (injecting the container itself, or a closure, and resolving the dependency only when actually needed instead of at construction time).

Minimal container API surface

bind() registers how to build $abstract, optionally as a singleton. get() is what everything else calls to actually retrieve an instance, triggering autowiring when no explicit binding exists. A Closure concrete is treated as a factory; a string concrete is treated as a class name to autowire.

interface Container
{
    public function bind(string $abstract, string|Closure $concrete, bool $singleton = false): void;
    public function singleton(string $abstract, string|Closure $concrete): void;
    public function get(string $abstract): object;
}

Code Examples

get() first checks for a cached singleton instance, then checks whether this abstract is already mid-resolution ($resolving) — if so, a circular dependency has been detected and a clear exception names the chain instead of recursing forever. autowire() uses ReflectionClass to inspect the constructor and recursively resolves each non-builtin typed parameter, which is what lets plain classes (with no explicit binding at all) still be built automatically.

if (isset($this->resolving[$abstract])) { throw ... } marks and checks the in-progress resolution chain, turning an infinite loop into an immediate, diagnosable error.

Common mistake: Omitting the $resolving guard — without it, two classes that depend on each other cause get() to call itself forever until PHP's memory limit is hit, with a stack trace that gives no hint about which two classes are actually involved.

The PDO binding is a singleton factory closure because building a connection needs configuration values, and the whole request should share one connection. PaymentGateway is a plain interface-to-class binding, letting every consumer depend on the interface instead of StripePaymentGateway directly. StripeClient uses a factory because it needs a config-derived constructor argument the container has no automatic way to supply. OrderRepository needs no binding at all — autowiring handles it because its constructor only type-hints things the container already knows how to build.

$container->bind(PaymentGateway::class, StripePaymentGateway::class); is the interface binding — swap the second argument to change providers everywhere at once.

Two classes needing each other at construction time is unresolvable by constructor injection alone — one side must give up eager construction. Here NotificationService accepts a Closure (bound in the container as fn () => $container->get(OrderService::class)) instead of an OrderService directly, so the container can finish building NotificationService without first needing a finished OrderService; the actual lookup only happens later, when the closure is invoked, by which point OrderService construction has completed.

private Closure $orderLookup — injecting a closure instead of the object itself breaks the cycle by deferring resolution until it's actually needed.

Common mistake: Trying to 'fix' a circular dependency by having the container silently return a half-constructed object for whichever side resolves second — that produces an object in an invalid state that fails unpredictably later, instead of failing loudly and immediately at wiring time.

<?php

final class Container
{
    private array $bindings = [];
    private array $instances = [];
    private array $resolving = [];

    public function bind(string $abstract, string|Closure $concrete, bool $singleton = false): void
    {
        $this->bindings[$abstract] = ['concrete' => $concrete, 'singleton' => $singleton];
    }

    public function singleton(string $abstract, string|Closure $concrete): void
    {
        $this->bind($abstract, $concrete, singleton: true);
    }

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

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

        try {
            $binding = $this->bindings[$abstract] ?? null;

            if ($binding && $binding['concrete'] instanceof Closure) {
                $object = ($binding['concrete'])($this);
            } else {
                $class = $binding['concrete'] ??
…
<?php

$container = new Container();

// Singleton: one PDO connection shared by the whole request.
$container->singleton(PDO::class, function (Container $c) {
    $config = $c->get(Config::class);
    return new PDO($config->get('database.dsn'), $config->get('database.user'), $config->get('database.pass'));
});

// Interface binding: anything asking for PaymentGateway gets StripePaymentGateway.
$container->bind(PaymentGateway::class, StripePaymentGateway::class);

// Factory: StripeClient needs a config value the container can't guess on its own.
$container->bind(StripeClient::class, function (Container $c) {
    $apiKey = $c->get(Config::class)->get('stripe.api_key');
    return new StripeClient($apiKey);
});

// No explicit binding needed: OrderRepository has only typed constructor
// params the container already knows how to resolve (PDO), so it is autowired.
$repository = $container->get(OrderRepository::class);
<?php

// Circular: OrderService needs NotificationService, which needs OrderService back.
final class OrderService
{
    public function __construct(private NotificationService $notifications) {}
}

final class NotificationService
{
    public function __construct(private OrderService $orders) {} // <-- cycle
}

// Fix: extract the shared need (looking up order details) into a narrow
// interface that NotificationService depends on instead of the full OrderService.
interface OrderLookup
{
    public function findSummary(int $orderId): OrderSummary;
}

final class OrderService implements OrderLookup
{
    public function __construct(private NotificationService $notifications) {}

    public function findSummary(int $orderId): OrderSummary { /* ... */ }
}

final class NotificationService
{
    // Depends only on the narrow interface OrderService happens to implement,
    // and the container can still supply it via an interface binding — but the
    // cycle is broken only if NotificationService is NOT itself required to
    // build the OrderLookup implementation. In practice this specific case
    // still needs one side to resolve lazily:
    public function __construct(private C
…

Resolution chain and cycle detection

The container tracks which abstracts are mid-resolution on the current call stack; seeing one reappear before it finished is the signal for a circular dependency.

get(OrderService)
  resolving = { OrderService }
  needs NotificationService
    get(NotificationService)
      resolving = { OrderService, NotificationService }
      needs OrderService
        get(OrderService)  ← already in `resolving` set!
        throw: circular dependency
                OrderService -> NotificationService -> OrderService

Important Rules

Bind interfaces to concrete classes so consumers depend on the interface, never on a specific implementation class name.

Use singleton lifecycle for shared, expensive-to-create, or intentionally-shared state (DB connections, config); use transient for lightweight or per-use objects.

Autowiring only resolves typed class/interface constructor parameters automatically — scalars and interfaces need an explicit binding.

Reach for a factory closure when construction needs configuration values, conditional logic, or steps beyond calling new.

A container should detect circular dependencies explicitly (via a resolving-chain check) and fail fast with a clear error, never loop or return a partially built object.

The real fix for a circular dependency is architectural — extract a shared interface, or make one side resolve lazily — not a container trick.

Where this shows up

Laravel's service container, Symfony's DependencyInjection component, and PHP-DI all implement exactly this set of concepts: bind()/singleton() style registration, reflection-based autowiring for classes with no explicit binding, and an explicit circular-dependency check that throws a descriptive exception rather than exhausting the stack. Reading Symfony's or Laravel's container source is a good next step once these fundamentals feel solid.

class NotificationService { public function __construct(private OrderService $orders) {} } while OrderService also constructor-injects NotificationService.

One side depends on a narrow interface or a lazily-resolved Closure instead of the other class's full constructor-injected instance.

Two classes requiring a finished instance of each other before either can finish construction is architecturally circular; no container configuration can make constructor injection satisfy both sides simultaneously.

$container->get($abstract) recurses into resolving dependencies with no tracking of what is currently being built.

$container->get($abstract) marks $abstract as 'resolving' before recursing, and throws a clear circular-dependency error if it sees that same abstract requested again mid-resolution.

Without a resolving-chain guard, a circular dependency causes unbounded recursion until PHP hits its memory or call-stack limit, producing a confusing fatal error instead of a clear diagnostic naming the cycle.

[Multiple Choice] What should a well-built service container do when it detects a circular dependency (A needs B, B needs A) during resolution? A. Silently return null for the second request B. Loop until PHP's memory limit is exhausted C. Track the in-progress resolution chain and throw a clear error identifying the cycle as soon as it reappears D. Automatically merge A and B into a single class

C. Track the in-progress resolution chain and throw a clear error identifying the cycle as soon as it reappears — A container should track which abstract types are currently being resolved on the call stack; when one of them is requested again before its first resolution finished, that is the signature of a cycle, and the container should fail immediately with a descriptive error rather than recursing forever.

[True / False] Autowiring can automatically resolve a constructor parameter that is type-hinted as a plain string with no default value. A. True B. False

B. False — False. Autowiring via reflection only knows how to resolve parameters typed as a class or interface it can build; a scalar type like string carries no information about what value to supply, so it needs an explicit binding or a default value.

Design container bindings for an application where ReportGenerator needs a Logger (shared across the whole request) and a PdfRenderer (needs an API key from configuration to construct). Decide the lifecycle for each binding and whether ReportGenerator itself needs an explicit binding at all.

$container->singleton(Logger::class, FileLogger::class); — shared, no config needed, autowired concrete class. $container->bind(PdfRenderer::class, function ($c) { return new PdfRenderer($c->get(Config::class)->get('pdf.api_key')); }); — factory, because of the config-derived constructor argument; transient unless rendering is expensive enough to justify singleton. ReportGenerator needs no explicit binding: its constructor only type-hints Logger and PdfRenderer, both of which the container already knows how to build, so $container->get(ReportGenerator::class) autowires it directly.

Think about which of the three classes has constructor parameters the container cannot resolve automatically.