Skip to main content

Dependency Graph Generation

Overview

generateDependencyGraph() inspects the container's declared bindings and reports what each one depends on — without ever instantiating anything. It only looks at reflection metadata, so calling it is guaranteed to never open a connection, mutate a registration, or run any other side effect a factory or constructor might have. This makes it safe to call in diagnostics, health checks, or CI, not just in a local debugging session.


Key Concepts

Declared Bindings Only:

The graph reflects only the abstracts currently registered via bind()/singleton(). It does not discover autowireable classes that were never explicitly bound.

Two Kinds of Entries:

Each binding is reported as either 'class' (its concrete is a class-string, so its constructor can be inspected directly via reflection) or 'dynamic' (its concrete is a Closure, so there is no concrete class to reflect on without invoking it — which the method intentionally never does).

No Instantiation, Ever:

A binding registered with a class-string concrete is introspected purely through ReflectionClass; a binding registered with a Closure concrete is reported with an empty dependency list instead of being executed.


Implementation Details

The functionality lives in DebuggingTrait:

generateDependencyGraph(): array

Returns array<string, array{kind: 'class'|'dynamic', dependencies: list<string>}> — one entry per registered abstract.

  • kind: 'class' — the binding's concrete is a class-string. dependencies lists the type of each constructor parameter, described as a string (a named type like App\Logger, a union like Foo|Bar, an intersection like Foo&Bar, or 'untyped' for a parameter with no type declaration).
  • kind: 'dynamic' — the binding's concrete is a Closure. dependencies is always [], since a closure's dependencies can't be discovered without calling it.

Example Usage

interface LoggerInterface {}
class FileLogger implements LoggerInterface {}

class UserService {
public function __construct(
private LoggerInterface $logger,
private ?string $prefix = null
) {}
}

$container = new Container();
$container->bind(LoggerInterface::class, FileLogger::class);
$container->bind(UserService::class, UserService::class);
$container->bind('config', fn () => ['debug' => true]);

$graph = $container->generateDependencyGraph();

/*
[
LoggerInterface::class => ['kind' => 'class', 'dependencies' => []],
UserService::class => ['kind' => 'class', 'dependencies' => ['LoggerInterface', 'string']],
'config' => ['kind' => 'dynamic', 'dependencies' => []],
]
*/

foreach ($graph as $service => $info) {
echo "Service: {$service} ({$info['kind']})\n";
echo "Dependencies: " . implode(', ', $info['dependencies']) . "\n\n";
}

UserService is introspected directly (its concrete is a class-string), while config — bound with a closure — shows up as 'dynamic' with no dependencies listed, because generating the graph never runs the closure to find out.


Benefits

  • Safe to Call Anywhere:
    Because it never invokes a factory or constructor, generateDependencyGraph() can run in production diagnostics without risk of triggering real side effects.

  • Enhanced Visibility:
    Provides a clear overview of how declared services depend on each other, and flags which bindings are opaque closures versus reflectable classes.

  • Simplified Debugging:
    Helps spot missing bindings or unexpected dependency shapes at a glance.


Summary

generateDependencyGraph() gives a side-effect-free snapshot of the container's declared bindings: class-backed bindings are introspected via reflection and report their constructor parameter types, while closure-backed bindings are reported as 'dynamic' with no dependencies, since the container never calls a closure just to build a diagnostic graph.