Package · di

Lazy proxies

#[Lazy] doesn’t inject the real object — it injects a native PHP 8.4 lazy proxy: a type-compatible stand-in that resolves the real instance from the container on first use. This is the mechanism behind breaking circular dependencies; here’s how it’s built.

The core call

When the resolver meets a #[Lazy] dependency, it builds the proxy with PHP 8.4’s native lazy objects rather than a hand-written wrapper:

php
$ref = ReflectionCache::classOf($type);

return $ref->newLazyProxy(
  static fn(object $proxy): object => $container->makeContextual($type, $consumer)
);

newLazyProxy() returns an object that is an instance of $type but carries no real state yet. The closure is the initializer: PHP calls it the first time any property or method of the proxy is touched, and whatever it returns becomes the backing instance. Here the initializer is a normal container resolution — makeContextual($type, $consumer) — so the lazily-built object goes through the same pipeline, contextual factories included.

The timeline

text
inject time     newLazyProxy(...)         → proxy created, initializer NOT run
                                          isUninitializedLazyObject() = true
 ...          (constructor returns, cycle avoided)
first access    $proxy->method()          → initializer runs: makeContextual($type)
                                          proxy now backed by the real instance
                                          isUninitializedLazyObject() = false
later access    $proxy->method()           → forwarded straight to the real instance

Because construction of the real object is deferred past the point where the injecting constructor returns, a dependency cycle no longer recurses infinitely — only one side needs to be lazy. That’s the whole trick behind breaking circular dependencies.

Why concrete classes only

A proxy has to be an instance of the target type — PHP generates it as a subclass-like stand-in. There’s nothing to subclass for an interface or an abstract class, so the resolver guards against both before calling newLazyProxy():

php
if (!class_exists($type)) {
  throw new ContainerException(
      "#[Lazy] requires a concrete class to proxy, got [{$type}]. "
      . 'Pair it with #[Inject(Concrete::class)] for interface-typed dependencies.'
  );
}
if (ReflectionCache::classOf($type)->isAbstract()) {
  throw new ContainerException("#[Lazy] cannot proxy an abstract class [{$type}].");
}

So for an interface-typed dependency you name the concrete class the proxy should stand in for:

php
public function __construct(
  #[Inject(SmsSendService::class), Lazy] private SendInterface $peer,
) {}

Here #[Inject] supplies the concrete $type (SmsSendService) the proxy is built from, while the parameter stays typed as the interface.

Transparency guarantees

The proxy is designed to be indistinguishable from the real object in normal use:

  • instanceof passes — the proxy is a real instance of $type, so type checks and signatures accept it.
  • Uninitialised until usedReflectionClass::isUninitializedLazyObject($proxy) reports true until the first access triggers the initializer.
  • Resolved once — after the initializer runs, the proxy is permanently backed by that instance; subsequent calls forward directly with no re-resolution.

Native, not userland

There’s no custom proxy class, no __call magic, and no code generation to maintain — this is the engine-level lazy-objects feature added in PHP 8.4, which is exactly why the package requires PHP ≥ 8.4.

Still an escape hatch

A lazy proxy resolves a symptom. A genuine circular dependency is usually better fixed by extracting the shared concern into a third service both sides depend on. Reach for #[Lazy] when that refactor isn’t worth it — the same stance Spring takes on @Lazy.