08 · Dependency Injection Basics¶
A class that reaches inside itself to create its own collaborators
(new MysqlLogger() buried in a constructor) is welded to that concrete
implementation — you can't swap it for a test double, a different backend,
or a decorated version without editing the class. Dependency injection
flips this: a class declares what it needs (through its constructor,
typically as interfaces) and something else — the caller, or a container
— supplies the concrete instance. Level 3's mocking module
already relied on this; here's why it works and how a container automates it.
Constructor injection¶
<?php
// interfaces + implementations
declare(strict_types=1);
interface Logger { public function log(string $msg): void; }
final class EchoLogger implements Logger {
public function log(string $msg): void { echo "[LOG] $msg\n"; }
}
interface Clock { public function now(): DateTimeImmutable; }
final class SystemClock implements Clock {
public function now(): DateTimeImmutable { return new DateTimeImmutable(); }
}
<?php
// ReportGenerator.php
declare(strict_types=1);
final class ReportGenerator
{
public function __construct(private Logger $logger, private Clock $clock) {}
public function generate(): string
{
$this->logger->log('generating report');
return 'Report as of ' . $this->clock->now()->format('Y-m-d');
}
}
ReportGenerator never writes new EchoLogger() or new SystemClock()
anywhere — it only knows about the Logger and Clock interfaces. That's
what makes it possible to hand it a FakeClock in a test (fixing "now" to a
known date) without touching this class at all, exactly the pattern
the mocking module exercised.
Manual wiring: fine for a handful of classes¶
<?php
declare(strict_types=1);
$logger = new EchoLogger();
$clock = new SystemClock();
$report = new ReportGenerator($logger, $clock);
echo $report->generate() . "\n";
This is dependency injection — nothing more exotic is required. A container becomes worth the complexity only once wiring by hand turns tedious: a class with five dependencies, each of which has its own dependencies, repeated across dozens of places that need one.
A minimal autowiring container¶
A container centralizes "how do I build a Logger?" in one place, and
can use PHP's Reflection API to inspect a class's constructor and build
its dependencies automatically — autowiring.
<?php
// Container.php
declare(strict_types=1);
final class Container
{
private array $bindings = [];
private array $instances = [];
/** Register how to build $abstract (an interface or class name). */
public function bind(string $abstract, callable $factory): void
{
$this->bindings[$abstract] = $factory;
}
public function get(string $abstract): mixed
{
if (isset($this->instances[$abstract])) {
return $this->instances[$abstract]; // reuse the same instance
}
if (isset($this->bindings[$abstract])) {
return $this->instances[$abstract] = ($this->bindings[$abstract])($this);
}
return $this->instances[$abstract] = $this->autowire($abstract);
}
private function autowire(string $class): object
{
$reflection = new ReflectionClass($class);
$constructor = $reflection->getConstructor();
if ($constructor === null) {
return new $class();
}
$args = [];
foreach ($constructor->getParameters() as $param) {
$type = $param->getType();
if ($type instanceof ReflectionNamedType && !$type->isBuiltin()) {
$args[] = $this->get($type->getName()); // recurse into ITS dependencies
} else {
throw new RuntimeException("Cannot autowire scalar parameter \${$param->getName()}");
}
}
return $reflection->newInstanceArgs($args);
}
}
<?php
// demo.php
declare(strict_types=1);
require __DIR__ . '/Container.php';
$container = new Container();
$container->bind(Logger::class, fn() => new EchoLogger());
$container->bind(Clock::class, fn() => new SystemClock());
$report = $container->get(ReportGenerator::class); // autowired: constructor deps resolved via reflection
echo $report->generate() . "\n";
$report2 = $container->get(ReportGenerator::class);
var_dump($report === $report2); // container cached the instance -> same object
Container::get(ReportGenerator::class) never appears in the bindings
array — the container falls through to autowire(), inspects
ReportGenerator's constructor via reflection, sees it needs a Logger
and a Clock, and resolves those through get() recursively, which finds
their explicit bindings. This is roughly how Laravel's service container
and Symfony's DI container work under the hood, at far larger scale.
PHP traps¶
Reflection can't autowire scalar parameters. getType() returns
ReflectionNamedType for int $limit too, but isBuiltin() is true for
scalars — the container above deliberately throws rather than guessing a
value, because there's no sane default for "what int should this be." Real
containers solve this with explicit binding of scalar/array parameters, or
by passing them at call time instead of relying on autowiring.
bind() closures run lazily, but get() caches eagerly. The factory
passed to bind() only runs the first time that abstract is requested;
after that, instances[$abstract] short-circuits get() and the same
object is handed out every time — that's why $report === $report2 is
true. If you actually want a new instance per get() call (a common
need for request-scoped objects), you must not populate $instances at
all for that binding — this container conflates "singleton" and "how to
build," which a production container makes an explicit choice
(singleton() vs. bind()).
A container is a tool for wiring, not a service locator to inject
everywhere. Passing the Container itself into a class and calling
$container->get(...) from inside it hides the class's real dependencies
and defeats the entire point of declaring them in the constructor — you
lose the ability to see what a class needs just by reading its signature,
and testing gets harder, not easier. Inject the container only at the
composition root (where the application is bootstrapped), never into
domain classes.
DI cheat sheet¶
| Concept | What it means |
|---|---|
| Constructor injection | Dependencies declared as typed constructor parameters |
| Depend on interfaces | Makes the concrete implementation swappable (real vs. fake) |
| Manual wiring | new A(new B(), new C()) — fine for a handful of classes |
| Container | Centralizes "how to build X"; resolves dependency graphs |
bind($abstract, $factory) |
Explicit recipe for building an interface/class |
| Autowiring | Container inspects constructors via Reflection and resolves automatically |
| Service locator (anti-pattern) | Injecting the container itself and calling ->get() inside a class |
How It Actually Works¶
Constructor injection is just ordinary function-call argument passing — there is no special engine support for "injecting" a dependency, it's the same argument-binding mechanism covered in the functions lesson, applied to a constructor instead of a free function; the "dependency injection" pattern is entirely a design discipline about who's responsible for constructing those arguments, not a distinct language feature. A minimal autowiring container works by using PHP's reflection API at runtime: ReflectionClass::getConstructor()->getParameters() inspects a class's constructor parameter list — including each parameter's declared type — without ever instantiating the class, and the container recursively resolves each typed parameter (by looking it up or recursively autowiring it) before finally calling $reflectionClass->newInstanceArgs($resolvedArgs), all of which is genuinely runtime introspection of compiled class metadata, not compile-time code generation the way some statically-typed DI frameworks work. This is precisely why autowiring can't resolve scalar/primitive constructor parameters (an int $timeout) automatically — reflection can tell the container a parameter is typed int, but there's no object graph to recursively construct for a primitive, only a value to supply, which is why real DI containers require explicit configuration for those cases. The performance cost of reflection-based autowiring (walking the constructor signature via reflection on every resolution) is also why production-grade containers precompile the resolution plan once and cache it, rather than reflecting on every single object construction.
Exercise¶
Add a NullLogger implements Logger (a log() that does nothing) and a
FixedClock implements Clock whose constructor takes a DateTimeImmutable
and always returns it from now(). Bind both into a second Container
instance (bind(Logger::class, fn() => new NullLogger()),
bind(Clock::class, fn() => new FixedClock(new DateTimeImmutable('2030-01-01')))),
resolve a ReportGenerator from it, and confirm generate() returns
"Report as of 2030-01-01" with no [LOG] line printed — proving the same
ReportGenerator class works unmodified against completely different
collaborators, purely by changing what the container binds.