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.
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:
Error: Typed property App\Heavy::$name must not be accessed before initializationMeasured 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 |
Related
- Breaking circular dependencies — the how-to
- Resolution lifecycle — where the proxy branch sits
- Concurrent resolution — what the container does guarantee under load
- Attributes —
#[Lazy]and#[Inject]