0

Circular dependencies — detection and resolution

Advanced5 min read·php-08-018
interviewframework

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:

  1. Extract a third service: If A and B both need common functionality, move that to a C that both depend on.
  2. 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.
  3. Event system / mediator: Instead of A calling B directly, A dispatches an event; B listens for it. They never directly reference each other.
  4. 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
<?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();