Categories
PHP

PHP Autowiring Container: Dolls Inside Dolls

Picture an order-list page. It has one job: print the orders on screen. I built a sample service graph and counted the constructors — seven objects were created during that single request, two were used. Five went straight to the bin untouched.

A PHP autowiring container is both the cure and the accomplice here. It wires your dependencies for you, and never mentions how many it built. I have written about dependency injection itself before (in Turkish), where we also hand-rolled a Di class. This one goes a step further: open the container up, measure what reflection actually costs, and use what the engine has been handing us for free since PHP 8.4.

The metaphor is a matryoshka. You pick up the outer doll, another one comes out of it, and another out of that. The container’s only job is knowing which doll goes inside which. Its only flaw: it opens every doll, whether you asked for them or not.

What a DI container is, and how it differs from dependency injection

Short definition: a DI container is an object that keeps, in one place, the knowledge of which class is built with which dependencies, and builds that object on request.

Dependency injection is a design decision: a class does not create what it needs, it receives it. A container is a tool. They are not the same thing, and neither requires the other. If you wrote a class that takes PDO in its constructor, you are doing DI, container or no container.

You reach for a container when wiring by hand becomes unbearable. To see where that threshold sits, let us wire it by hand first.

Where hand-wiring breaks down

// Wiring the order-list page by hand
$config     = new Config();
$logger     = new Logger($config);
$database   = new Database($config, $logger);
$pdf        = new PdfRenderer($logger);
$mail       = new MailQueue($database, $logger);
$service    = new ReportService($database, $pdf, $mail);
$controller = new ReportController($service, $logger);

echo $controller->index();

Seven lines, seven objects. It works, it reads, it needs no packages. So far so good. The problem is not the line count, it is the order: you cannot build Database before Logger, nor Logger before Config. You are the one memorising the graph.

The painful part: this block gets written once per entry point. Two entry points, two copies. Forty of them, and the day you add a parameter to Logger’s constructor you are opening forty files. That, not performance, is the problem a container actually solves.

A hundred lines of container: how autowiring works

Autowiring means the container reads the type hints on constructor parameters and builds the required objects itself. All of it is framework-agnostic plain PHP, and it pulls in nothing from outside:

<?php
declare(strict_types=1);

final class ServiceNotFound extends RuntimeException {}
final class CircularDependency extends RuntimeException {}

final class Container
{
    /** @var array<string, callable> Hand-written factories */
    private array $factories = [];
    /** @var array<string, object> Resolved instances */
    private array $instances = [];
    /** @var array<string, true> Currently being built */
    private array $building = [];

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

    public function get(string $id): object
    {
        if (isset($this->instances[$id])) {
            return $this->instances[$id];
        }
        if (isset($this->building[$id])) {
            throw new CircularDependency(
                'Circular dependency: ' . implode(' -> ', array_keys($this->building)) . ' -> ' . $id
            );
        }

        $this->building[$id] = true;
        try {
            $object = isset($this->factories[$id])
                ? ($this->factories[$id])($this)
                : $this->autowire($id);
        } finally {
            unset($this->building[$id]);
        }

        return $this->instances[$id] = $object;
    }

    /** Reads the type hints and fills the constructor itself. */
    private function autowire(string $id): object
    {
        if (!class_exists($id)) {
            throw new ServiceNotFound("Undefined service: {$id}");
        }

        $class = new ReflectionClass($id);
        if (!$class->isInstantiable()) {
            throw new ServiceNotFound("Not instantiable (abstract or interface): {$id}");
        }

        $constructor = $class->getConstructor();
        if ($constructor === null) {
            return new $id();
        }

        $args = [];
        foreach ($constructor->getParameters() as $parameter) {
            $type = $parameter->getType();

            if ($type instanceof ReflectionNamedType && !$type->isBuiltin()) {
                $args[] = $this->get($type->getName());
                continue;
            }
            if ($parameter->isDefaultValueAvailable()) {
                $args[] = $parameter->getDefaultValue();
                continue;
            }

            throw new ServiceNotFound(
                "Cannot resolve \${$parameter->getName()} in {$id}::__construct(): "
                . 'no class type, no default value'
            );
        }

        return $class->newInstanceArgs($args);
    }
}

The mechanism fits in three steps: grab the constructor with ReflectionClass::getConstructor(), walk the parameters with getParameters(), and whenever a parameter’s ReflectionNamedType is not a builtin, ask the container for that class. The recursion is the matryoshka itself: it descends to the innermost doll and comes back up.

The $building array is the seatbelt. If an id that is already under construction gets requested again, you get a readable exception instead of infinite recursion. We will see what that looks like shortly.

What does reflection actually cost?

Reflection has a reputation for being expensive. No talking without measuring. I built a four-class graph 20,000 times: once through reflection, once through hand-written factory code (roughly what a compiled container generates).

Path20,000 requestsPer request
Reflection autowiring (4 classes)62–66 ms0.0032 ms
Hand-written factory (4 classes)7.6–16.2 ms0.0004 ms
Reflection autowiring (120 classes)158–196 ms / 2,000 requests0.079–0.098 ms
PHP 8.4.21 CLI, single process, no caching; the container is rebuilt from scratch every request.

The ratio is close to eight. The absolute number is three microseconds per request. Even on a 120-class graph it stays under a tenth of a millisecond.

So: reflection is not your bottleneck. The order of a single WHERE clause (in Turkish) or one N+1 loop eats a thousand times what you see in that table. Compiling the container earns its keep when your application really does build hundreds of services per request. Before that, it is premature optimisation.

Three things autowiring cannot solve

Reading a type hint is not reading an intention. The container above stops in three places, and these three messages are real output, not decorative comments:

$c = new Container();

// 1) Cart -> Pricing -> Cart
$c->get(Cart::class);
// CircularDependency: Circular dependency: Cart -> Pricing -> Cart

// 2) Payment::__construct(private string $apiKey)
$c->get(Payment::class);
// ServiceNotFound: Cannot resolve $apiKey in Payment::__construct():
//                  no class type, no default value

// 3) Shipment::__construct(private Carrier $carrier)  — Carrier is an interface
$c->get(Shipment::class);
// ServiceNotFound: Undefined service: Carrier

// The third one takes a single line to fix: bind the interface
$c->set(Carrier::class, static fn (Container $c): Carrier
    => new LocalCarrier(getenv('CARRIER_API_KEY') ?: 'test-key'));

echo $c->get(Shipment::class)->send('1042'); // LC-1042
  • Circular dependency: if two classes ask for each other in their constructors, the container is helpless. The fix lives in your design, not in the container: pass one through a setter, or move the shared work into a third class. (The error disappears once we switch to lazy objects; below I explain why you should not rely on that.)
  • Scalar parameter: reflection cannot tell you the value of string $apiKey. That belongs to configuration, and you define it on the container by hand.
  • Interface: the most misunderstood limit of autowiring. If Carrier is an interface, the container cannot know which concrete class to build. You make that call — which is a good thing anyway.

So the container wires ninety per cent of it automatically and you write the other ten. That ten per cent is where your application’s real decisions live.

Since PHP 8.4, laziness lives in the engine

Here is the answer to the opening. Five of those seven objects were wasted because the container builds everything eagerly on the way into the constructor. PdfRenderer was never going to run on that request, and it was built anyway.

The old answer to this was a proxy-generating library. PHP 8.4 removed the need: lazy objects are now the engine’s own business. The RFC, written by Arnaud Le Blanc and Nicolas Grekas, passed 26 to 5 and shipped with PHP 8.4.

  • Lazy ghost: the object initialises itself, in place. Once initialised it is indistinguishable from an ordinary object.
  • Lazy proxy: a factory returns the real instance and the proxy forwards everything to it. This is the one you need when initialisation is delegated elsewhere — to a container, for instance.

The second is what a container wants. Every service that is handed over as a dependency goes out as a proxy rather than a real object:

// One line changes inside Container::autowire():
//   $args[] = $this->get($type->getName());
// becomes:
$args[] = $this->lazyGet($type->getName());

// ...and this method joins the class:

/** Returns the dependency as a lazy proxy instead of a real instance. */
    private function lazyGet(string $id): object
    {
        if (!class_exists($id)) {
            return $this->get($id); // let get() throw
        }

        $class = new ReflectionClass($id);
        $proxy = $class->newLazyProxy(fn (): object => $this->get($id));

        // A class with no properties cannot be lazy: hand over the real thing.
        return $class->isUninitializedLazyObject($proxy) ? $proxy : $this->get($id);
    }

Same graph, same machine, two containers:

ContainerObjects builtTimeBuilt
Eager750.5 msConfig, Logger, Database, PdfRenderer, MailQueue, ReportService, ReportController
Lazy proxies430.3 msReportController, ReportService, Database, Config
Identical numbers across three runs; the order list returned its two rows correctly either way.

Being honest about it: the millisecond gap is a number from my fixture. I put 30 and 20 millisecond sleeps into the Database and PdfRenderer constructors to stand in for connecting and font loading, so the saving is exactly the PdfRenderer that never ran. The portable number is not the milliseconds, it is four instead of seven. Your milliseconds will be worth whatever those skipped constructors actually cost you.

One more detail: Logger is missing from the list, because the code that wanted it only called a method, and that method touched no property. What wakes a proxy is not a method call, it is state being read or written.

A nice surprise: the circular dependency resolves

Something I did not expect happened on the lazy path. The Cart → Pricing → Cart cycle that stopped the container a moment ago now builds, because the Pricing that Cart receives is an uninitialised proxy. I tried it in both directions:

// Cart -> Pricing -> Cart ; with the lazy container:
$cart = $c->get(Cart::class);
$cart->add('keyboard', 2);
$cart->add('mouse', 1);

echo $cart->total();                            // 360
echo $c->get(Pricing::class)->cartLineCount();  // 2

Two keyboards and a mouse come to 360 with 20 per cent VAT, and the reverse direction sees the same cart’s two lines. One object, both directions, working.

But: this is a cover, not a cure. The cycle is still there, waiting quietly for the first touch. If two constructors depend on each other, fix the design; a lazy proxy only buys you the right to crash at seven in the evening instead of seven in the morning.

Two traps in lazy objects

First: a class with no properties cannot be lazy. As the manual puts it, an object is not marked lazy when there are no properties left to mark as lazy. isUninitializedLazyObject() returns false for such a class, which is why the code above has that check. Skip the check and you end up passing around a proxy you believe is lazy while it is in fact already built.

Second: internal classes are out entirely. ArrayObject, PDO, DateTime — all of them are refused:

// Trying a lazy proxy on an internal class
$r = new ReflectionClass(ArrayObject::class);
$r->newLazyProxy(fn (): ArrayObject => new ArrayObject());

// Error: Cannot make instance of internal class lazy: ArrayObject is internal

In practice this means you make an expensive connection lazy through your own wrapper class rather than through PDO directly. Which is what you should have been doing anyway.

Whether the PHP in front of you supports this takes one line to find out (I also wrote up the log of that version upgrade):

php -r 'var_dump(method_exists(ReflectionClass::class, "newLazyProxy"));'

PSR-11, and the key under the doormat

The standard you write a container against is PSR-11, and it is smaller than people expect: Psr\Container\ContainerInterface asks for two methods — get(string $id) and has(string $id): bool. Alongside it sit two exception interfaces, ContainerExceptionInterface and NotFoundExceptionInterface which extends it. The rule is firm: if has() returns false, get() must throw NotFoundExceptionInterface.

The specification also carries a warning, and that is the valuable part: a container should not be handed to an object so that the object can pull its own dependencies out of it. That pattern is called Service Locator, and PSR-11 names it an anti-pattern outright.

In matryoshka terms: the container is the hand that passes you the doll, not a drawer you reach into. If your class asks for Database in its constructor, its dependencies are readable. If it asks for the container and pulls get('db') out of it, the dependency list is hidden inside the method bodies. Locking the door and leaving the key under the doormat.

Which one, and when?

SituationRecommendationWhy
5–10 classes, one entry pointSkip the containerSeven lines of hand-wiring read better; the abstraction costs more than it returns
A few entry points, your own codeYour own container, like the one aboveA hundred lines, zero dependencies, error messages in your own words
Many packages, many teams, complicated wiring rulesAn off-the-shelf containerPHP-DI, league/container and Symfony DependencyInjection are all MIT licensed, and definition files, tags and decorators come included
Hundreds of services really are built per requestA compiled containerMoves reflection out of request time into generated factory code
Construction is expensive (connections, fonts, large files)Lazy proxiesAn object that is never built costs nothing, and since PHP 8.4 it needs no library
Licences verified from the projects’ GitHub repositories on 1 October 2026.

On projects where I use a framework most of these decisions are already made for me; the framework’s container is sitting right there. On the framework-free work, the class above is a starting point I copy and add a line or two to.

Before you open the matryoshka

The charm of a matryoshka is that it looks like one doll from the outside. That is exactly where a container becomes dangerous: get() is a single line and it never tells you how many objects were built behind it. Remember the opening — seven built, two used, nobody noticed.

Do three things. Count the objects you build, once. Make the expensive ones lazy. Keep the container out of your constructors.

Last time I wrote about a compiler that casts PHP into a mould, and the subject there was the price of giving up dynamism. This piece is the other face of that coin: a hundred lines that read types at runtime and build objects is useful precisely because it is too dynamic to be cast. The bill for the flexibility we keep calling expensive turns out, once measured, to be three microseconds.

Leave a Reply

Your email address will not be published. Required fields are marked *