Package · di

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:

php
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.

src/SmsSendService.php
<?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]:

php
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.