0

Building a minimal IoC container from scratch (preview of Track 3)

Advanced5 min read·lv-02-012
framework

Concept

Building a minimal IoC container from scratch is the best way to demystify what Laravel's Illuminate\Container\Container does internally. A production-ready minimal container needs four capabilities: binding (registering factories), singleton caching, automatic resolution via Reflection, and an optional alias layer. Everything else in Laravel's container is either an optimization or a convenience API on top of these four primitives.

The core data structures mirror what you studied in lesson lv-02-009: a $bindings array holding factories and a shared flag, and an $instances array caching resolved singletons. The build() method uses ReflectionClass to inspect constructor parameters and recursively resolves each typed dependency by calling make() on its type. This is the key insight: the container is self-referential — make() calls build(), which calls make() for each dependency.

This preview connects directly to the Framework track (Track 3), where you will build a full-featured container with contextual bindings, event hooks, PSR-11 compliance, and method injection. The minimal version here has about 80 lines of PHP and handles the same core cases you encounter daily in Laravel applications.

The critical difference between a toy container and Laravel's is the handling of primitive constructor parameters. A plain ReflectionParameter without a class type hint cannot be auto-resolved — the container has no way to know what integer or string to pass. Laravel handles this through contextual bindings, $with parameter overrides passed to make(), and the resolveNonClass() method (which checks for a default value before throwing BindingResolutionException). Your minimal container will throw on unresolvable primitives, which is the correct behavior for a learning implementation.

The circular dependency problem is worth noting: if A depends on B and B depends on A, the container will recurse infinitely into build(). Laravel's container does not automatically detect this — the stack will overflow with a PHP fatal error. The solution is to redesign the dependency graph (usually by introducing a third class or using lazy resolution with a Closure).

Code Example

php
<?php

declare(strict_types=1);

namespace Framework\Container;

use Closure;
use ReflectionClass;
use ReflectionParameter;
use Psr\Container\ContainerInterface;

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 ---

    public function bind(string $abstract, Closure|string $concrete): void
    {
        $this->bindings[$abstract] = [
            'concrete' => $this->wrapConcrete($abstract, $concrete),
            'shared'   => false,
        ];
    }

    public function singleton(string $abstract, Closure|string $concrete): void
    {
        $this->bindings[$abstract] = [
            'concrete' => $this->wrapConcrete($abstract, $concrete),
            'shared'   => true,
        ];
    }

    public function instance(string $abstract, object $instance): void
    {
        $this->instances[$abstract] = $instance;
    }

    // --- Resolution API ---

    public function make(string $abstract, array $with = []): mixed
    {
        // Return cached singleton first.
        if (isset($this->instances[$abstract])) {
            return $this->instances[$abstract];
        }

        $concrete = $this->bindings[$abstract]['concrete'] ?? $abstract;
        $shared   = $this->bindings[$abstract]['shared']   ?? false;

        $object = $this->build($concrete, $with);

        if ($shared) {
            $this->instances[$abstract] = $object;
        }

        return $object;
    }

    /** @throws BindingResolutionException */
    protected function build(Closure|string $concrete, array $with = []): object
    {
        // If it's a factory closure, call it with the container as argument.
        if ($concrete instanceof Closure) {
            return $concrete($this, $with);
        }

        // Auto-resolution via Reflection.
        $reflector = new ReflectionClass($concrete);

        if (!$reflector->isInstantiable()) {
            throw new BindingResolutionException(
                "Target [{$concrete}] is not instantiable."
            );
        }

        $constructor = $reflector->getConstructor();

        // No constructor — just instantiate.
        if ($constructor === null) {
            return new $concrete();
        }

        $dependencies = $this->resolveDependencies($constructor->getParameters(), $with);

        return $reflector->newInstanceArgs($dependencies);
    }

    /** @param ReflectionParameter[] $parameters */
    protected function resolveDependencies(array $parameters, array $with = []): array
    {
        $results = [];

        foreach ($parameters as $parameter) {
            $type = $parameter->getType();

            // Named override takes priority.
            if (isset($with[$parameter->getName()])) {
                $results[] = $with[$parameter->getName()];
                continue;
            }

            // Type-hinted class → resolve recursively.
            if ($type instanceof \ReflectionNamedType && !$type->isBuiltin()) {
                $results[] = $this->make($type->getName());
                continue;
            }

            // Fall back to default value.
            if ($parameter->isDefaultValueAvailable()) {
                $results[] = $parameter->getDefaultValue();
                continue;
            }

            throw new BindingResolutionException(
                "Unresolvable dependency [{$parameter->getName()}] in class [{$parameter->getDeclaringClass()?->getName()}]."
            );
        }

        return $results;
    }

    protected function wrapConcrete(string $abstract, Closure|string $concrete): Closure
    {
        if ($concrete instanceof Closure) {
            return $concrete;
        }
        // Wrap string class name in a closure so build() always gets a Closure.
        return fn(Container $c, array $with) => $c->build($concrete, $with);
    }

    // --- PSR-11 ---

    public function get(string $id): mixed
    {
        try {
            return $this->make($id);
        } catch (BindingResolutionException $e) {
            throw new EntryNotFoundException($id, 0, $e);
        }
    }

    public function has(string $id): bool
    {
        return isset($this->bindings[$id]) || isset($this->instances[$id]);
    }
}

Interview Q&A

Q: How would you build a minimal IoC container from scratch, and what are the essential components?

A minimal IoC container needs three data structures and four methods. The structures are: $bindings (abstract → factory + shared flag), $instances (singleton cache), and optionally $aliases. The methods are bind(), singleton(), make(), and build(). make() checks the singleton cache first, then retrieves the factory from $bindings (defaulting to the abstract class name for auto-resolution), calls build(), caches if shared, and returns. build() checks whether the concrete is a closure (call it directly) or a class string (use ReflectionClass to inspect the constructor, recursively resolve each typed parameter via make(), then instantiate with newInstanceArgs()). These roughly 80 lines replicate the core of Illuminate\Container\Container::build() and make().


Q: How does the container handle circular dependencies, and how would you design around them?

Neither Laravel's container nor a minimal hand-built container can automatically detect circular dependencies — A depends on B depends on A will cause infinite recursion and a PHP stack overflow. Laravel does not attempt to detect this because the overhead of tracking the resolution stack on every make() call would be significant for a hot path. The correct solution is to redesign the dependency graph. The most common approaches are: introduce a third class that both A and B depend on, inject a factory Closure instead of the resolved object (so the circular reference is broken until the object is actually needed), or use setter injection for one of the dependencies so both constructors can complete before the circular reference is formed. In Laravel specifically, if you see infinite recursion, enabling debug mode will show the stack trace revealing which class is being resolved recursively.


Q: What is the difference between a container that resolves by auto-wiring and one that requires explicit binding, and what are the trade-offs?

An auto-wiring container (like Laravel's) uses ReflectionClass to read constructor type hints and resolves dependencies without any explicit bind() calls. This reduces boilerplate — most classes work out of the box if their dependencies are also concrete classes. The trade-off is that Reflection adds overhead (mitigated in Laravel by the $resolved cache and OPcache), and the container cannot resolve interfaces or abstract classes without an explicit binding (it cannot know which implementation to use). An explicit-only container (like a simple service locator) requires you to register every type, which is verbose but produces fully explicit, easily auditable configuration. Laravel takes a hybrid approach: auto-wire concrete classes, but require explicit bindings for interfaces and abstract classes, which is the right balance for application development.