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:
$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
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 instanceBecause 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():
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:
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:
instanceofpasses — the proxy is a real instance of$type, so type checks and signatures accept it.- Uninitialised until used —
ReflectionClass::isUninitializedLazyObject($proxy)reportstrueuntil 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.
Related
- Breaking circular dependencies — the how-to
- Resolution lifecycle — where the proxy branch sits
- Attributes —
#[Lazy]and#[Inject]