Auto-wiring — using Reflection to resolve constructor parameters
Concept
Auto-wiring is the container's most powerful feature: the ability to instantiate a class without any explicit binding, by reading the constructor's type-hints through PHP's Reflection API. When you call $container->make(UserService::class) and no binding exists, the container falls back to inspecting the class itself — looking at every constructor parameter, resolving each typed dependency recursively, and assembling the full object graph automatically.
The process relies on ReflectionClass and ReflectionParameter. For each constructor parameter PHP gives us a ReflectionNamedType; if it names a class or interface the container already knows about (or can build), we recurse into make(). If it's a primitive (int, string, bool) we check for a default value. This is exactly how Illuminate\Container\Container::build() works internally — resolve() calls build(), which calls getDependencies(), which maps each reflected parameter to a resolved value.
The key architectural decision is separating "do we have a binding?" from "can we build it?" These are two different code paths. Our make() method first checks the $bindings map; only when nothing is found does it attempt Reflection-based construction. This mirrors Laravel's approach in Container::resolve() where isBuildable() controls which path runs.
One subtlety: when a dependency's type is an interface, auto-wiring alone cannot help — interfaces cannot be instantiated. This is why explicit bind() calls for interface-to-concrete mappings still matter. Auto-wiring handles the concrete-class case; bindings handle the abstraction-to-implementation case. Together they form a complete dependency resolution system.
After this lesson our container can build entire object graphs from a single make() call on a concrete class, provided all transitive dependencies are either concrete classes or explicitly bound interfaces.
Code Example
<?php
declare(strict_types=1);
namespace Framework\Container;
use Closure;
use ReflectionClass;
use ReflectionException;
use ReflectionNamedType;
use ReflectionParameter;
use Psr\Container\ContainerInterface;
use Framework\Container\Exceptions\BindingResolutionException;
class Container implements ContainerInterface
{
/** @var array<string, array{concrete: Closure|string, shared: bool}> */
protected array $bindings = [];
/** @var array<string, object> */
protected array $instances = [];
// -------------------------------------------------------------------------
// Binding API (from previous lessons)
// -------------------------------------------------------------------------
public function bind(string $abstract, Closure|string $concrete, bool $shared = false): void
{
if (is_string($concrete)) {
$concrete = $this->wrapClass($concrete);
}
$this->bindings[$abstract] = ['concrete' => $concrete, 'shared' => $shared];
}
public function singleton(string $abstract, Closure|string $concrete): void
{
$this->bind($abstract, $concrete, shared: true);
}
public function instance(string $abstract, object $instance): void
{
$this->instances[$abstract] = $instance;
}
protected function wrapClass(string $concrete): Closure
{
return fn (Container $container) => $container->build($concrete);
}
// -------------------------------------------------------------------------
// PSR-11 has() / get()
// -------------------------------------------------------------------------
public function has(string $id): bool
{
return isset($this->bindings[$id]) || isset($this->instances[$id]);
}
public function get(string $id): mixed
{
return $this->make($id);
}
// -------------------------------------------------------------------------
// Resolution entry point
// -------------------------------------------------------------------------
public function make(string $abstract): mixed
{
// Return cached singleton / instance() registrations first.
if (isset($this->instances[$abstract])) {
return $this->instances[$abstract];
}
// If a binding exists, use its factory closure.
if (isset($this->bindings[$abstract])) {
$binding = $this->bindings[$abstract];
$object = ($binding['concrete'])($this);
if ($binding['shared']) {
$this->instances[$abstract] = $object;
}
return $object;
}
// No binding — fall back to Reflection-based auto-wiring.
$object = $this->build($abstract);
return $object;
}
// -------------------------------------------------------------------------
// Auto-wiring via Reflection
// -------------------------------------------------------------------------
/**
* Instantiate a concrete class, resolving all its constructor dependencies.
*
* This is the heart of auto-wiring. Laravel's equivalent is
* Illuminate\Container\Container::build().
*
* @throws BindingResolutionException
*/
public function build(string $concrete): object
{
try {
$reflector = new ReflectionClass($concrete);
} catch (ReflectionException $e) {
throw new BindingResolutionException(
"Target class [{$concrete}] does not exist.",
previous: $e
);
}
if (! $reflector->isInstantiable()) {
$this->notInstantiable($concrete);
}
$constructor = $reflector->getConstructor();
// No constructor — just instantiate directly.
if ($constructor === null) {
return new $concrete();
}
$dependencies = $this->resolveDependencies($constructor->getParameters());
return $reflector->newInstanceArgs($dependencies);
}
/**
* Resolve an array of ReflectionParameters to actual values.
*
* @param ReflectionParameter[] $parameters
* @return array<int, mixed>
*
* @throws BindingResolutionException
*/
protected function resolveDependencies(array $parameters): array
{
$dependencies = [];
foreach ($parameters as $parameter) {
$dependencies[] = $this->resolveParameter($parameter);
}
return $dependencies;
}
/**
* Resolve a single constructor parameter.
*
* Precedence:
* 1. Typed class / interface → recurse into make()
* 2. Has a default value → use the default
* 3. Primitive with no default → throw
*/
protected function resolveParameter(ReflectionParameter $parameter): mixed
{
$type = $parameter->getType();
// If the parameter has a class/interface type, resolve it from the container.
if ($type instanceof ReflectionNamedType && ! $type->isBuiltin()) {
return $this->make($type->getName());
}
// Fall back to the default value if one is declared.
if ($parameter->isDefaultValueAvailable()) {
return $parameter->getDefaultValue();
}
// Unresolvable — the container cannot supply a primitive with no default.
throw new BindingResolutionException(
sprintf(
'Unresolvable dependency resolving [%s] in class {%s}.',
$parameter->getName(),
$parameter->getDeclaringClass()?->getName() ?? 'unknown'
)
);
}
/**
* Throw a descriptive exception for non-instantiable targets.
*
* @throws BindingResolutionException
*/
protected function notInstantiable(string $concrete): never
{
throw new BindingResolutionException(
"Target [{$concrete}] is not instantiable. " .
"Did you forget to bind an interface to a concrete class?"
);
}
}<?php
// Usage demonstration — no bindings needed for concrete classes.
$container = new Framework\Container\Container();
// These concrete classes are resolved automatically via Reflection.
class Logger {}
class Database {
public function __construct(public readonly Logger $logger) {}
}
class UserRepository {
public function __construct(
public readonly Database $db,
public readonly Logger $logger,
) {}
}
$repo = $container->make(UserRepository::class);
// $repo->db->logger is the same Logger instance? No — each make() creates fresh.
// Wrap in singleton() to share across the graph:
$container->singleton(Logger::class, Logger::class);
$repo2 = $container->make(UserRepository::class);
// Now $repo2->db->logger === $repo2->logger (same instance).Interview Q&A
Q: How does auto-wiring work at the PHP engine level — what exactly does the Reflection API give you?
ReflectionClass::getConstructor() returns a ReflectionMethod whose getParameters() yields an array of ReflectionParameter objects. Each parameter exposes getType(), which returns ReflectionNamedType for single-type hints. Calling isBuiltin() on that type tells you whether it is a PHP scalar (int, string, etc.) or a user-land class/interface name. When it is not builtin, getName() gives the fully-qualified class name, which you pass back into make() recursively. This chain is exactly what Illuminate\Container\Container::resolveDependencies() does — the method names differ slightly but the logic is identical.
Q: What is the difference between how our container and Laravel's handle the case where a constructor parameter is a primitive (int, string) with no default?
Both throw an exception. Our implementation throws BindingResolutionException directly from resolveParameter(). Laravel's Container::resolveNonClass() in Container.php checks whether the parameter has a default and throws BindingResolutionException with the message "Unresolvable dependency resolving [$param]" — the same semantics. The key difference is that Laravel also supports contextual bindings with primitive values (covered in fw-02-007): you can tell the container "when building ReportService, pass 100 as $timeout". Our container does not yet support that.
Q: Why does auto-wiring break for interfaces, and how do you fix it?
ReflectionClass::isInstantiable() returns false for interfaces and abstract classes because PHP cannot new them. The container detects this and throws rather than hanging. The fix is a binding: $container->bind(LoggerInterface::class, FileLogger::class). When make() sees a type-hint of LoggerInterface, it finds the binding and resolves FileLogger instead. This is the Dependency Inversion Principle in action — the consuming class depends on an abstraction, and the container wires in the concrete implementation at runtime.