Dependency Injection Containers in PHP: Building a Minimal One From Scratch
A Dependency Injection (DI) container is a class that knows how to construct other classes automatically, resolving their constructor dependencies recursively, so the rest of the application never has to call new directly on anything with dependencies. Most explanations describe what a container does; this one builds a minimal, working version to show exactly how it does it — the same core mechanism used by the DI container component described in our framework overview.
The Problem a Container Solves
Without a container, wiring up an object graph by hand looks like this, and gets worse as dependencies nest deeper:
```php
$repository = new OrderRepository($database);
$inventory = new InventoryRepository($database);
$service = new OrderCancellationService($repository, $inventory);
$controller = new OrderController($service);
```
Every new dependency added to any of these classes means updating every place that constructs them. A container removes this by inspecting a class's constructor and building its dependencies automatically.
Step 1: Resolving a Class With No Dependencies
The core tool PHP gives us for this is Reflection — specifically, ReflectionClass, which can inspect a class's constructor parameters at runtime.
```php
class Container
{
public function make(string $class)
{
$reflection = new ReflectionClass($class);
$constructor = $reflection->getConstructor();
if (! $constructor) {
return new $class();
}
// dependencies handled in the next step
}
}
```
Step 2: Resolving Constructor Dependencies Recursively
For a class with dependencies, we inspect each constructor parameter's type, and resolve it the same way — recursively, so a chain of dependencies gets built automatically:
```php
class Container
{
public function make(string $class)
{
$reflection = new ReflectionClass($class);
$constructor = $reflection->getConstructor();
if (! $constructor) {
return new $class();
}
$dependencies = [];
foreach ($constructor->getParameters() as $parameter) {
$type = $parameter->getType();
if (! $type || $type->isBuiltin()) {
throw new RuntimeException(
"Cannot resolve parameter '{$parameter->getName()}' in {$class}"
);
}
$dependencies[] = $this->make($type->getName());
}
return $reflection->newInstanceArgs($dependencies);
}
}
```
With this, $container->make(OrderController::class) automatically builds OrderCancellationService, which automatically builds OrderRepository and InventoryRepository, with zero manual wiring — as long as every class in the chain has type-hinted constructor parameters.
Step 3: Binding Interfaces to Concrete Classes
Reflection alone can't resolve an interface — OrderRepositoryInterface has no constructor to inspect, because it's not instantiable. The container needs an explicit binding, telling it which concrete class to use whenever that interface is requested:
```php
class Container
{
private array $bindings = [];
public function bind(string $abstract, string $concrete): void
{
$this->bindings[$abstract] = $concrete;
}
public function make(string $class)
{
if (isset($this->bindings[$class])) {
$class = $this->bindings[$class];
}
$reflection = new ReflectionClass($class);
$constructor = $reflection->getConstructor();
if (! $constructor) {
return new $class();
}
$dependencies = [];
foreach ($constructor->getParameters() as $parameter) {
$type = $parameter->getType();
if (! $type || $type->isBuiltin()) {
throw new RuntimeException(
"Cannot resolve parameter '{$parameter->getName()}' in {$class}"
);
}
$dependencies[] = $this->make($type->getName());
}
return $reflection->newInstanceArgs($dependencies);
}
}
$container = new Container();
$container->bind(OrderRepositoryInterface::class, OrderRepository::class);
$container->bind(InventoryRepositoryInterface::class, InventoryRepository::class);
$controller = $container->make(OrderController::class);
```
This is exactly what makes the repository pattern's testability possible in a real framework: production code binds the interface to the real repository, while a test can bind the same interface to a fake instead, and every class built through the container automatically receives whichever one is currently bound — without any of those classes needing to know which one they got.
What Full Containers Add on Top of This
Production-grade containers (Laravel's, Symfony's) build on this exact mechanism, adding: singleton bindings (build once, reuse the same instance), contextual bindings (a different concrete class depending on which class is asking), and caching of reflection results, since reflecting on the same class repeatedly on every request is unnecessarily slow. The core resolution logic, though, is the recursive reflection walk shown above.
When Autowiring Isn't Enough
Reflection-based autowiring breaks down for scalar constructor arguments — a class that needs, say, an API key string or a numeric config value can't be resolved by type alone, since PHP's type system doesn't distinguish "this string" from "that string." Real containers handle this with explicit factory closures for such classes, falling back to autowiring only for object dependencies.
Frequently Asked Questions
Is building a custom DI container a good idea for a real project?
Generally no — mature containers already handle singleton scoping, contextual bindings, circular dependency detection, and performance caching correctly. Building one is primarily valuable for understanding the mechanism, the same reasoning covered for building a custom framework more broadly in our overview article.
Does autowiring make code slower?
Reflection has a real cost, which is why production containers cache the reflected dependency graph rather than re-analyzing it on every request. Without caching, yes, it adds measurable overhead on a hot path.
Do I need interfaces everywhere for a container to be useful?
No — the container resolves concrete classes with no bindings needed at all. Bindings are specifically for the cases where more than one implementation could satisfy a given type, which is exactly the interface + swappable-implementation pattern used for repositories.
Need a PHP codebase's architecture untangled, container included? Get in touch.