# What compiled DI containers actually buy you

> Container compilation moves reflection from request time to build time. What that removes, what it costs, and when it matters.

- Published: 2026-08-13
- Tags: dependency-injection, symfony, opcache
- Source: https://elephantphp.com/blog/what-compiled-di-containers-actually-buy-you/
- Language: en-US
- Author: Alden Pike

---
The same application, the same wiring, the same twenty services resolved on a
request. Through Symfony's `ContainerBuilder` used directly that took 0.157 ms;
through the container that same builder dumps to generated PHP it took 0.027
ms. Nothing about the application changed between the two — only when the
wiring was worked out.

## Method

PHP 8.5.9, NTS, arm64, Homebrew build, on an Apple M4 Pro laptop running macOS,
not quiesced. `symfony/dependency-injection` 8.1.5. Figures come from PHP's
built-in server serving one script, timed with `hrtime()` inside the process
around two phases separately: obtaining the container, and resolving services
from it. `opcache.enable=1`, `opcache.jit=disable`,
`opcache.file_update_protection=0`. Fifteen interleaved runs per variant, three
warmup rounds discarded. Tables give medians; run-to-run spread is stated with
each table, and given as a range per cell where the columns are close. Every
response printed its own effective configuration and OPcache counters, which is
how I know the variants differed rather than all landing on the default.

The graph is one service class per repository class, each service depending on
its repository and a shared logger, each repository on a shared connection.
Services and repositories are non-shared, so resolving 20 services constructs 40
objects plus three shared ones. The classes a run instantiates are required
before timing starts, so the figures are container work rather than autoloading,
which both variants pay identically.

## What a runtime container does per resolution

Asked for a service, a runtime container reads its definition, resolves each
constructor argument — recursively, because arguments are mostly references to
other services — and constructs the object. Symfony's `ContainerBuilder` walks
`Definition` objects to do it. Laravel's container reflects over the constructor
and resolves each type-hinted parameter. Either way the work lands on the
request, and it lands again on the next one, because a PHP request inherits
nothing from the last: the shared-nothing model described in
[the path a request takes from source to
opcodes](/blog/what-happens-when-php-runs-your-code/) throws away the object
graph along with everything else.

## What compilation moves

Compiling a container performs that resolution once and writes a PHP class with
one method per service. Symfony's `PhpDumper` produced this for a service
depending on a non-shared repository and a shared logger, rewrapped here because
the generator emits the constructor call on a single line:

```php
protected static function getSvc0Service($container)
{
    $container->factories['svc0'] = function ($container) {
        return new \Gen50\Svc0(
            new \Gen50\Repo0(($container->services['connection'] ?? self::getConnectionService($container))),
            ($container->services['logger'] ?? self::getLoggerService($container)),
        );
    };

    return $container->factories['svc0']($container);
}
```

There is no definition to read and no argument to resolve. The repository was
inlined into the constructor call because nothing else shares it, and the two
shared dependencies became an array probe with a fallback. The request-time work
is a method call over code the compiler already reasoned about.

## The saving has two parts and they scale differently

Holding the number of resolutions at 20 and varying how many services the
container defines separates them. Medians in milliseconds:

| Definitions | Runtime, obtain | Runtime, resolve | Compiled, obtain | Compiled, resolve |
|---|---|---|---|---|
| 54 | 0.0448 | 0.0645 | 0.0154 | 0.0114 |
| 204 | 0.0942 | 0.0633 | 0.0152 | 0.0113 |
| 1,004 | 0.3654 | 0.0630 | 0.0152 | 0.0114 |
| 4,004 | 1.5690 | 0.0663 | 0.0158 | 0.0123 |

Spread on that table ran from 2.0% of the median to 44%, widest on the
54-definition row and on the two compiled columns at 4,004, where the quantity
timed is a few microseconds and one slow run drags the range a long way. The 204
and 1,004 rows, which carry the argument, sat between 2.0% and 8.1%. Those are
figures for this series: a spread is a property of one run, not a quantity a
re-run reproduces the way a median is.

Resolution cost is flat across a seventy-fold change in container size, for both
variants. Building the definitions is not: the runtime column rises roughly in
proportion to how many services exist, reaching 1.57 ms at four thousand, where
it dwarfs the resolution it exists to serve. The compiled columns do not move.

Varying the other axis — 1,004 definitions throughout, changing only how many
services a request resolves — isolates the per-resolution difference:

| Resolutions | Runtime | Compiled | Ratio |
|---|---|---|---|
| 1 | 0.0098 (0.0092–0.0145) | 0.0015 (0.0014–0.0018) | 6.5x |
| 5 | 0.0212 (0.0209–0.0214) | 0.0036 (0.0035–0.0037) | 5.9x |
| 20 | 0.0640 (0.0630–0.0701) | 0.0114 (0.0113–0.0140) | 5.6x |
| 100 | 0.2833 (0.2814–0.3045) | 0.0500 (0.0490–0.0553) | 5.7x |
| 300 | 0.8387 (0.8324–0.8550) | 0.1420 (0.1408–0.1468) | 5.9x |

Spread does not fall cleanly with size. The single-resolution row is the loosest
— 54% of the median in the runtime column, 27% in the compiled one — where a few
microseconds is near what this method can resolve. But the five-resolution
runtime cell is the tightest in the table at 2.4%, and the 20-resolution
compiled cell is 24%. What holds on every row is the separation: no runtime
range comes near its compiled counterpart, the closest approach being a factor
of 4.5, so the ratio column survives the noise even where the noise is large.

Both are linear in resolutions and the ratio stays between 5.6x and 6.5x, which
works out at about 2.3 µs saved per service resolved. The runtime container's
obtain phase stayed between 0.3696 and 0.3798 ms across that whole column,
confirming it depends on definitions rather than resolutions.

So the sentence "the saving scales with what a request builds, not with the size
of the container" is true of the per-resolution half and false of the other
half. A framework that rebuilds its definitions on every request pays for its
whole container whether or not the request touches it.

One result went against my expectation. A forty-line container that autowires by
reflecting constructors at resolution time — the shape Laravel uses — resolved
the same 20 services in 0.0162 ms against the compiled container's 0.0113, but
with no obtain phase at all, so its total of 0.0163 ms beat the compiled
container's 0.0265 ms. It is not a fair fight: mine handles type-hinted
constructor parameters and nothing else, no interface bindings, no factories, no
lazy services. Read it as evidence that reflection is cheaper than its
reputation, not that compilation loses.

## The compiled container is a file, and OPcache is why it is free

That flat obtain column depends on something. Turning OPcache off and changing
nothing else:

| Definitions | OPcache on | OPcache off |
|---|---|---|
| 204 | 0.0154 (0.0148–0.0213) | 1.0150 (0.9819–1.0769) |
| 1,004 | 0.0154 (0.0148–0.0211) | 3.4235 (3.4144–3.5583) |
| 4,004 | 0.0155 (0.0148–0.0299) | 12.9366 (12.7245–13.1540) |

Generated code is still code. The dumped container at four thousand definitions
is a 2.1 MB PHP file, and without a cache each request pays to compile it. The
first request of a fresh process pays it once regardless: 2.58 ms at 204
definitions, 8.65 ms at 1,004, 35.42 ms at 4,004. Compilation trades request
work for a large file, and [what OPcache removes and what it does
not](/blog/opcache-jit-and-where-time-actually-goes/) is what makes that trade
come out ahead.

## The costs, and one of them is a benefit

The build step is real. Defining, compiling and dumping 4,004 services took 265
ms, of which 107 ms was the compiler passes and 153 ms the dump. That runs on
deploy, not on a request, but it runs, and the generated file must be
invalidated when the wiring changes — which is what a cache clear is for. Edit a
service definition without regenerating and the application keeps the previous
wiring, silently and correctly, until something rebuilds it.

The error class moves too. I registered a service pointing at `mailerr` when
`mailer` was meant. The uncompiled container served a request that resolved
`mailer` without noticing, because nothing asked for the broken service.
Compiling refused:

```text
Symfony\Component\DependencyInjection\Exception\ServiceNotFoundException:
The service "invoice_sender" has a dependency on a non-existent service
"mailerr". Did you mean this: "mailer"?
```

A wiring mistake on a path no test covers is a production incident under a
runtime container and a failed build under a compiled one.

## Where this is not where your time is

At 20 services a request, compilation saved about 46 µs of resolution. A single
loopback round trip on this machine cost roughly 86 µs in [the measurements
behind the OPcache article](/blog/opcache-jit-and-where-time-actually-goes/) —
a different series, so compare the orders of magnitude rather than the digits.
One avoidable database query outweighs the entire container. If a profiler shows
the request waiting on I/O, this is not the lever, and the method for finding
out is in [benchmarking PHP without lying to
yourself](/blog/benchmarking-php-without-lying-to-yourself/).

## What to do with this

Compile the container if your framework offers it, and take the reason to be
correctness rather than speed. Wiring errors surfacing at build time is worth
more than 46 µs, and the performance argument only becomes interesting where a
request resolves hundreds of services or the container defines thousands.

Keep OPcache on, because the compiled container's advantage is contingent on it,
and how contingent depends on size. Timing both phases together with the cache
off, in a separate series of eleven runs, the compiled container still won at
204 definitions — 1.07 ms against 1.47 ms — and lost at 4,004, at 13.04 ms
against 2.83 ms. Past some container size, compiling a multi-megabyte generated
file on every request costs more than building the definitions it replaced.

Measure the resolution count before treating the container as a suspect. Twenty
services at 2.3 µs of saving each is not where a slow request went, and the
container is one of the easiest things in a framework to blame and one of the
harder ones to profile honestly.
