Break circular dependencies
When A needs B and B needs A, eager resolution can’t terminate — the container detects the
cycle and throws. #[Lazy] is the escape hatch: it injects a proxy instead of the real object,
deferring resolution to first use, so only one side of the cycle needs to change.
The symptom
Two services that reference each other trigger the circular-dependency guard:
class SmsSendService
{
public function __construct(private FakeSendService $peer) {}
}
class FakeSendService
{
public function __construct(private SmsSendService $peer) {}
}
$container->make(SmsSendService::class);
// ContainerException: Circular dependency detected while resolving [SmsSendService].Resolving SmsSendService needs FakeSendService, which needs SmsSendService again — an
infinite regress the container refuses to enter.
The fix — make one side lazy
Add #[Lazy] to one side of the cycle. It injects a native lazy proxy: a stand-in that
looks and behaves exactly like the real class but doesn’t build it until the first method call.
By then the constructor has already returned, so the cycle never recurses.
<?php
use Flytachi\Winter\DI\Attribute\Lazy;
use Flytachi\Winter\DI\Attribute\Autowired;
class SmsSendService
{
#[Lazy]
private FakeSendService $peer; // proxy now; real make() on first $this->peer->...()
public function send(string $to): void
{
$this->peer->deliver($to); // proxy resolves here, on first access
}
}
class FakeSendService
{
#[Autowired]
private SmsSendService $peer; // eager back-reference — the cycle no longer recurses
}#[Lazy] works on both properties and constructor parameters, alone or combined with
#[Autowired] / #[Inject].
Interfaces need a concrete class
A proxy has to stand in for a concrete class — it can’t proxy an interface or abstract
class, because there is nothing to subclass. When the dependency is typed as an interface, name
the concrete with #[Inject]:
public function __construct(
#[Inject(SmsSendService::class), Lazy] private SendInterface $peer,
) {}A bare #[Lazy] on an interface-typed dependency throws a clear ContainerException telling
you to pair it with #[Inject(Concrete::class)]. Same for an abstract class.
The proxy is transparent
The injected proxy is type-compatible with the real class (instanceof passes) and stays
uninitialised until first use — ReflectionClass::isUninitializedLazyObject() reports true
until then. Your code can’t tell it apart from the real object. How the native proxy works is
covered in Lazy proxies.
Prefer redesigning the cycle
A circular dependency is usually a design smell. The cleanest fix is to extract the shared part
into a third service both sides depend on. #[Lazy] is the pragmatic escape when that
refactor isn’t worth it — the same stance Spring takes on @Lazy.
Related
- Lazy proxies — how the native PHP 8.4 proxy is built
- Resolution lifecycle — where the cycle guard lives
- Attributes —
#[Lazy],#[Inject],#[Autowired]