Circular dependencies — detection and resolution
Concept
A circular dependency occurs when class A depends on class B, and class B depends on class A (directly or through a chain). In a DI container, this means the container can't construct either object without the other already existing — it gets stuck in an infinite loop or throws an error.
Why circular dependencies are usually design errors: They indicate two classes that are too tightly coupled. If A needs B and B needs A, they may be doing too much or should be combined, or one dependency should be inverted through an interface and a third component.
Detection: Laravel's container throws BindingResolutionException when it detects a circular dependency during auto-resolution. Symfony detects them at container compile time and throws a ServiceCircularReferenceException.
Resolution strategies:
- Extract a third service: If A and B both need common functionality, move that to a C that both depend on.
- Property injection / setter injection: Instead of constructor injection for one direction of the cycle, inject via
setB()after construction. This breaks the construction-time circular dependency. - Event system / mediator: Instead of A calling B directly, A dispatches an event; B listens for it. They never directly reference each other.
- Lazy injection: Inject a container or a callable that resolves B lazily, rather than B itself. Laravel's
app()helper inside a method is technically this pattern.
Code Example
<?php
declare(strict_types=1);
// Circular dependency — BAD design
class OrderService
{
public function __construct(
private InvoiceService $invoices, // needs InvoiceService
) {}
public function completeOrder(int $id): void
{
$this->invoices->generate($id);
}
}
class InvoiceService
{
public function __construct(
private OrderService $orders, // needs OrderService ← CYCLE!
) {}
public function generate(int $orderId): void
{
$order = $this->orders->find($orderId);
}
}
// new OrderService(new InvoiceService(new OrderService(...))) — infinite!
// ===== Resolution 1: Extract shared service =====
class OrderRepository
{
public function find(int $id): array { return []; }
}
class OrderServiceFixed
{
public function __construct(private OrderRepository $repo, private InvoiceServiceFixed $invoices) {}
public function completeOrder(int $id): void { $this->invoices->generate($id); }
}
class InvoiceServiceFixed
{
public function __construct(private OrderRepository $repo) {} // no OrderService!
public function generate(int $orderId): void { $order = $this->repo->find($orderId); }
}
// ===== Resolution 2: Event-driven (mediator/event bus) =====
interface EventBus
{
public function dispatch(object $event): void;
}
class OrderCompleted { public function __construct(public int $orderId) {} }
class OrderServiceEvent
{
public function __construct(private EventBus $bus) {}
public function completeOrder(int $id): void
{
$this->bus->dispatch(new OrderCompleted($id)); // no direct dependency
}
}
class InvoiceServiceEvent
{
public function onOrderCompleted(OrderCompleted $event): void
{
// generate invoice for $event->orderId — no circular dep
}
}
// ===== Resolution 3: Setter injection to break construction cycle =====
class A
{
private ?B $b = null;
public function setB(B $b): void { $this->b = $b; }
public function doSomething(): void { $this->b?->help(); }
}
class B
{
public function __construct(private A $a) {} // A is safe to construct here
public function help(): void { echo "Helping\n"; }
}
$a = new A(); // construct A first (no deps)
$b = new B($a); // construct B with A
$a->setB($b); // inject B into A via setter
$a->doSomething();