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.

A limit under Swoole: initialisation is not atomic

A lazy proxy is PHP’s own mechanism, and its initialisation is not guarded against concurrent access. If building the real service does I/O — connecting to a database, calling over the network — the coroutine that started the initialisation suspends on that I/O; a second one reaching the same proxy starts no second initialisation and sees a half-built object:

text
Error: Typed property App\Heavy::$name must not be accessed before initialization

Measured on plain PHP with no container involved: the factory runs once, and the second coroutine gets that error. The container cannot help here — PHP never hands it control on the second access.

It is not about the scope, it is about the shared instance. Checked across all three: the error appears wherever one holder object is visible to two coroutines at once, and does not when each coroutine has its own:

Holder Simultaneous accesses Result
#[Singleton] two requests error
#[Request] two subtasks of one request (#[Async], own coroutines) error
transient, instance handed to several coroutines two subtasks error
transient, each coroutine does its own make() two requests clean

So #[Singleton] is the most common way to end up with a shared proxy, not the only one: starting parallel subtasks inside a request that work through one service is enough.

Three conditions have to meet, and removing any one is enough:

Condition How to remove it
the holder instance is shared between coroutines give each its own — make() inside the subtask, not outside it
building does I/O move connecting out of the constructor into the first method call
the accesses are simultaneous resolve the service up front, before starting the subtasks