Building a Minimal DI Container From Scratch: Understanding PSR-11
AI generated
<?php
8.4
PHP · PSR-11 · Dependency Injection · OOP
Building a Minimal DI Container From Scratch
to actually understand PSR-11

Anyone who only knows a DI container from a framework's documentation rarely understands why it is designed the way it is designed. In this article we build a minimal, PSR-11-compliant DI container from the ground up in plain PHP 8.4: with reflection-based autowiring, recursive dependency resolution, cycle detection, and clearly defined bindings between interface and implementation. What comes out is not production code, but a tool for understanding, one that shows what PHP-DI, Symfony DependencyInjection, and the Laravel container actually do under the hood.

18 min read PSR-11 · Reflection · Autowiring · Bindings PHP 8.4 · strict_types · Framework-agnostic

1. Why PSR-11, and what a DI container actually does

At its core, a DI container is nothing more than a registry that knows how objects are created and automatically assembles those objects together with their dependencies. As soon as an application spans more than a handful of classes, manually wiring up constructor calls becomes tedious and error-prone: every new dependency of a class forces changes everywhere that class is instantiated. A DI container solves exactly this problem by resolving the entire object graph at runtime, instead of developers assembling it by hand.

It matters to draw a clear line against the service locator, a related but fundamentally different pattern. A service locator is actively asked for a dependency from inside a class ($locator->get(Logger::class) somewhere in a method body), which makes the class implicitly dependent on the entire locator and hides its real dependencies from its constructor. A DI container, by contrast, injects dependencies from the outside, usually via the constructor, so every class openly declares its requirements in its signature. PSR-11 standardizes exactly the boundary between the two worlds: the ContainerInterface describes how to obtain an object, but says nothing about how a class is built internally, that remains the container's own job.

2. The minimal interface: ContainerInterface with get() and has()

PSR-11 deliberately defines an extremely lean interface for a DI container: just two methods, get(string $id): mixed and has(string $id): bool. get() returns an instance for the given identifier or throws an exception if nothing is found or resolution fails. has() only answers whether the container can theoretically provide something for that identifier, but explicitly does not guarantee that a subsequent get() call will succeed. This deliberately weak guarantee lets containers implement has() cheaply, without running the full resolution process.

Two exception types complete the interface: NotFoundExceptionInterface for when the identifier is completely unknown to the container, and ContainerExceptionInterface as a more general error type for all other problems during resolution, for example a failed instantiation. Every concrete exception class of a container must additionally extend a real exception class, because these PSR interfaces themselves do not extend any executable base class. The following code shows the complete PSR-11 interface as defined by the psr/container package.


<?php

declare(strict_types=1);

namespace Psr\Container;

use Throwable;

// The two exception types every PSR-11 container must be able to throw
interface ContainerExceptionInterface extends Throwable
{
}

interface NotFoundExceptionInterface extends ContainerExceptionInterface
{
}

// The only two methods a PSR-11 container is required to implement
interface ContainerInterface
{
    /**
     * Finds an entry of the container by its identifier and returns it.
     *
     * @throws NotFoundExceptionInterface  No entry was found for this identifier.
     * @throws ContainerExceptionInterface Error while retrieving the entry.
     */
    public function get(string $id): mixed;

    /**
     * Returns true if the container can return an entry for the given identifier.
     * Returning true does not guarantee that get() will not throw an exception.
     */
    public function has(string $id): bool;
}

These three interfaces are everything PSR-11 mandates. No bind(), no singleton(), no configuration syntax, because how binding works is explicitly an implementation detail, not part of the standard. This exact smallness makes PSR-11 the ideal starting point for building your own DI container: you only need to implement two methods to be compatible with every PSR-11-compliant framework.

3. Autowiring via reflection: constructor analysis at runtime

The heart of every modern DI container is autowiring: the ability to instantiate a class without anyone having manually listed its dependencies. This is made possible by PHP's reflection API. ReflectionClass opens a class up for introspection at runtime, getConstructor() returns the constructor as a ReflectionMethod, and getParameters() returns an array of ReflectionParameter objects, from which the name, type, and any existing default value of each parameter can be read.

For every parameter, the DI container checks whether a type hint exists and is not a primitive type, ReflectionNamedType::isBuiltin() distinguishes between classes or interfaces and scalar types like string or int. If the type is a class or an interface, the container recursively asks itself for that dependency, which ultimately leads to a cascade of get() calls that build the entire object graph from the leaves to the root. If there is no type hint, or it is a primitive type, the container falls back to a default value, if one is defined in the constructor, otherwise the dependency must be explicitly configured, more on that in the sections on bindings and parameter overrides.


<?php

declare(strict_types=1);

namespace Mironsoft\DiContainer;

use Psr\Container\ContainerExceptionInterface;
use ReflectionClass;
use ReflectionNamedType;
use ReflectionParameter;

final class ContainerException extends \RuntimeException implements ContainerExceptionInterface
{
}

final class MinimalContainer
{
    /**
     * Builds a fresh instance of the given class by reading its constructor
     * signature at runtime and resolving every typed parameter recursively.
     *
     * @throws ContainerExceptionInterface
     */
    private function autowire(string $className): object
    {
        $reflection = new ReflectionClass($className);

        if (!$reflection->isInstantiable()) {
            throw new ContainerException("Class {$className} is not instantiable.");
        }

        $constructor = $reflection->getConstructor();

        if ($constructor === null) {
            return new $className();
        }

        $arguments = array_map(
            fn (ReflectionParameter $parameter): mixed => $this->resolveParameter($parameter),
            $constructor->getParameters(),
        );

        return $reflection->newInstanceArgs($arguments);
    }

    /**
     * Resolves a single constructor parameter: class-typed parameters are
     * requested from the container itself, everything else falls back to a default.
     *
     * @throws ContainerExceptionInterface
     */
    private function resolveParameter(ReflectionParameter $parameter): mixed
    {
        $type = $parameter->getType();

        if ($type instanceof ReflectionNamedType && !$type->isBuiltin()) {
            /** @var class-string $className */
            $className = $type->getName();

            return $this->get($className);
        }

        if ($parameter->isDefaultValueAvailable()) {
            return $parameter->getDefaultValue();
        }

        throw new ContainerException(
            "Cannot resolve parameter \${$parameter->getName()}: no type hint and no default value.",
        );
    }
}

This form of autowiring works entirely without a configuration file, as long as every dependency is injected through clearly typed constructor parameters, exactly the pattern that constructor property promotion makes idiomatic in PHP 8.4. A DI container that relies purely on autowiring gets by with minimal configuration overhead for cleanly typed class hierarchies.

4. Recursive resolution of dependencies and cycle detection

As soon as a DI container resolves dependencies recursively, a new risk emerges: circular dependencies. Class A requires an instance of B in its constructor, B in turn requires an instance of A. Without a guard mechanism, the container calls get(A::class), which calls get(B::class), which in turn calls get(A::class), and so on, until the PHP process crashes with a stack overflow error, usually with a less than helpful message.

The solution is a resolution stack: a simple array that holds every identifier currently being processed during resolution. Before the DI container resolves a new class, it checks whether its identifier is already on the stack. If so, a cycle is undeniably present, and the container can immediately throw a meaningful exception showing the complete resolution path, instead of letting the process starve in an infinite loop. After every resolution, whether it finishes successfully or with an exception, the identifier must be removed from the stack again, a classic case for a try/finally block.


<?php

declare(strict_types=1);

// Continuing the MinimalContainer class: cycle detection via a resolution stack

final class MinimalContainer
{
    /** @var list<string> Identifiers currently being resolved, used for cycle detection. */
    private array $resolutionStack = [];

    /**
     * Resolves an identifier while guarding against circular dependencies.
     * Every class currently being built is pushed onto the resolution stack;
     * if the same class reappears before it is popped, a cycle exists.
     *
     * @throws ContainerException
     */
    private function resolve(string $id): object
    {
        if (in_array($id, $this->resolutionStack, true)) {
            $path = implode(' -> ', [...$this->resolutionStack, $id]);

            throw new ContainerException("Circular dependency detected: {$path}");
        }

        $this->resolutionStack[] = $id;

        try {
            return $this->autowire($id);
        } finally {
            array_pop($this->resolutionStack);
        }
    }
}

Circular dependencies are almost always a sign of an architectural problem, not a missing container trick. When two classes need each other, extracting a third, shared abstraction, or switching to an optional method dependency instead of a constructor dependency, usually helps. A good DI container surfaces the problem early and with a clear error message, instead of hiding it.

5. Defining bindings: interface to concrete implementation, singleton vs. transient factory

Pure autowiring is not enough once a class depends on an interface instead of a concrete class, the normal case in any codebase built on service contracts. Reflection has no way of knowing which concrete implementation of an interface is meant, because an interface has no instantiable constructor. This is exactly where the DI container's binding table comes in: a simple mapping from an abstract identifier, usually an interface name, to a concrete class name, typically configured through a method like bind(string $abstract, string $concrete): void.

The second important dimension is the lifecycle of a resolved instance. A transient identifier creates a new instance on every get() call, the safe default for stateless services. An identifier marked as a singleton, on the other hand, is only built on the first call, after that the DI container returns the same cached instance on every further get() call. Database connections, loggers, and configuration objects are classic candidates for singleton bindings, while value objects and short-lived command handlers should generally stay transient, to avoid unexpected shared behavior.

6. Registering closures as factories, parameter overrides for primitives

Not every dependency can be meaningfully built via autowiring. Some classes need complex initialization logic, a connection to an external resource, or values that come from the environment at runtime, for example an API key or a database DSN. For these cases, a flexible DI container allows registering a closure as a factory: an anonymous function invoked on demand, typically receiving the container itself as a parameter so it can load further dependencies if needed.

Closely related is the problem of primitive constructor parameters. Reflection cannot automatically resolve a parameter of type string or int, because there are infinitely many possible strings and integers, and none of them is uniquely determined by a type hint. A DI container therefore needs a mechanism for parameter overrides: a targeted mapping from class name and parameter name to a concrete value, which takes precedence over autowiring and default values during parameter resolution. This allows, for example, injecting a timeout value or a directory path into exactly the class that needs it, without affecting every other class that happens to have a same-named parameter.


<?php

declare(strict_types=1);

// Continuing the MinimalContainer class: closures as factories and manual overrides

final class MinimalContainer
{
    /** @var array<string, Closure> Registered factory closures, keyed by identifier. */
    private array $factories = [];

    /**
     * @var array<string, array<string, mixed>> Manual parameter overrides per class,
     *     used for primitives that autowiring cannot resolve on its own.
     */
    private array $parameterOverrides = [];

    /**
     * Registers a closure that is invoked lazily the first time the identifier
     * is requested. The closure receives the container itself as its only argument.
     */
    public function bindFactory(string $id, Closure $factory): void
    {
        $this->factories[$id] = $factory;
    }

    /**
     * Registers a concrete value for a constructor parameter that autowiring
     * cannot resolve, e.g. a string, integer or array without a matching type hint.
     */
    public function bindParameter(string $className, string $parameterName, mixed $value): void
    {
        $this->parameterOverrides[$className][$parameterName] = $value;
    }
}

Closures as factories and parameter overrides complement each other: a plain override is enough for simple primitives, a factory closure is the cleaner choice for complex objects with side effects during creation. Both mechanisms preserve the central advantage of a DI container: the consuming class knows nothing about where its dependencies come from, it only declares what it needs in its constructor.

7. The complete code: a MinimalContainer class in PHP 8.4

All the building blocks discussed so far, the PSR-11 interface, reflection autowiring, cycle detection, bindings, and factories, can be combined into a single, manageable class. The following DI container consistently uses PHP 8.4 syntax: declare(strict_types=1), constructor property promotion for the container's own configuration, and readonly wherever a value should no longer change after construction. The class is deliberately kept compact, well under 150 lines of code for fully functional autowiring with bindings, singletons, factories, and cycle detection.

It matters what this implementation deliberately leaves out: no attribute-based configuration, no caching of compiled definitions, no support for variadic parameters or union types in constructors. That is intentional, because the goal of this DI container is not production use, but understanding the mechanics that also sit behind more elaborate containers.


<?php

declare(strict_types=1);

namespace Mironsoft\DiContainer;

use Closure;
use Psr\Container\ContainerExceptionInterface;
use Psr\Container\ContainerInterface;
use Psr\Container\NotFoundExceptionInterface;
use ReflectionClass;
use ReflectionNamedType;
use ReflectionParameter;

final class ContainerException extends \RuntimeException implements ContainerExceptionInterface
{
}

final class NotFoundException extends \RuntimeException implements NotFoundExceptionInterface
{
}

final class MinimalContainer implements ContainerInterface
{
    /** @var array<string, object> Cached singleton instances, keyed by identifier. */
    private array $instances = [];

    /** @var array<string, string> Interface-to-implementation bindings. */
    private array $bindings = [];

    /** @var array<string, bool> Identifiers that should be resolved as singletons. */
    private array $singletons = [];

    /** @var array<string, Closure> Registered factory closures, keyed by identifier. */
    private array $factories = [];

    /** @var array<string, array<string, mixed>> Manual constructor parameter overrides. */
    private array $parameterOverrides = [];

    /** @var list<string> Identifiers currently being resolved, used for cycle detection. */
    private array $resolutionStack = [];

    public function __construct(
        private readonly bool $autowireByDefault = true,
    ) {
    }

    /**
     * Binds an abstract identifier (usually an interface) to a concrete class name.
     */
    public function bind(string $abstract, string $concrete, bool $singleton = false): void
    {
        $this->bindings[$abstract] = $concrete;

        if ($singleton) {
            $this->singletons[$abstract] = true;
        }
    }

    /**
     * Registers a closure factory for an identifier, invoked lazily on first use.
     */
    public function bindFactory(string $id, Closure $factory, bool $singleton = false): void
    {
        $this->factories[$id] = $factory;

        if ($singleton) {
            $this->singletons[$id] = true;
        }
    }

    /**
     * Registers a concrete value for a constructor parameter autowiring cannot resolve.
     */
    public function bindParameter(string $className, string $parameterName, mixed $value): void
    {
        $this->parameterOverrides[$className][$parameterName] = $value;
    }

    public function get(string $id): object
    {
        if (isset($this->instances[$id])) {
            return $this->instances[$id];
        }

        $instance = $this->resolve($id);

        if (isset($this->singletons[$id])) {
            $this->instances[$id] = $instance;
        }

        return $instance;
    }

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

    /**
     * @throws ContainerException
     */
    private function resolve(string $id): object
    {
        if (in_array($id, $this->resolutionStack, true)) {
            $path = implode(' -> ', [...$this->resolutionStack, $id]);

            throw new ContainerException("Circular dependency detected: {$path}");
        }

        $this->resolutionStack[] = $id;

        try {
            if (isset($this->factories[$id])) {
                return ($this->factories[$id])($this);
            }

            $target = $this->bindings[$id] ?? $id;

            return $this->autowire($target);
        } finally {
            array_pop($this->resolutionStack);
        }
    }

    /**
     * @throws NotFoundException
     * @throws ContainerException
     */
    private function autowire(string $className): object
    {
        if (!class_exists($className)) {
            throw new NotFoundException("Class {$className} does not exist.");
        }

        $reflection = new ReflectionClass($className);

        if (!$reflection->isInstantiable()) {
            throw new ContainerException("Class {$className} is not instantiable.");
        }

        $constructor = $reflection->getConstructor();

        if ($constructor === null) {
            return new $className();
        }

        $arguments = array_map(
            fn (ReflectionParameter $parameter): mixed => $this->resolveParameter($className, $parameter),
            $constructor->getParameters(),
        );

        return $reflection->newInstanceArgs($arguments);
    }

    /**
     * @throws ContainerException
     */
    private function resolveParameter(string $className, ReflectionParameter $parameter): mixed
    {
        $overrides = $this->parameterOverrides[$className] ?? [];

        if (array_key_exists($parameter->getName(), $overrides)) {
            return $overrides[$parameter->getName()];
        }

        $type = $parameter->getType();

        if ($type instanceof ReflectionNamedType && !$type->isBuiltin()) {
            /** @var class-string $dependency */
            $dependency = $type->getName();

            return $this->get($dependency);
        }

        if ($parameter->isDefaultValueAvailable()) {
            return $parameter->getDefaultValue();
        }

        if ($type?->allowsNull()) {
            return null;
        }

        throw new ContainerException(
            "Cannot resolve parameter \${$parameter->getName()} of {$className}: no type hint, binding, or default value.",
        );
    }
}

8. Limits of building your own: performance on large object graphs, attributes, when a mature container makes more sense

The DI container built here re-runs the complete reflection analysis on every get() call for an uncached instance: ReflectionClass and ReflectionParameter are freshly created on every call. For small object graphs with a few dozen classes, that is no noticeable problem, but for large applications with hundreds of services and deep dependency chains, the reflection overhead adds up measurably, especially on every HTTP request in a classic PHP request-response architecture without a persistent worker process.

Mature containers like PHP-DI compile the resolution logic once into optimized PHP code that gets by without reflection on subsequent calls, an approach Symfony DependencyInjection also follows with its container compiler pass. In addition, established containers support attribute-based configuration, for example #[Inject] or #[Autowire], which let bindings be annotated directly on the class instead of maintained centrally in a configuration file. For a small script, a learning project, or a deliberately minimalist library, a homegrown DI container remains entirely sufficient, but as soon as performance under load, attribute configuration, compilation, or a large ecosystem of integrations is required, switching to PHP-DI, Symfony DependencyInjection, or the Laravel container is the more economical decision.

9. A homegrown container in direct comparison to established containers

The following table places the DI container built in this article side by side with the three best-known PHP containers. None of the established containers is strictly bound to a particular framework: PHP-DI can be used in any PHP project whatsoever, Symfony DependencyInjection is also used outside the full Symfony framework, and the Laravel container fundamentally works as a standalone Composer package.

Approach Autowiring Performance When to use
Homegrown container Yes, via reflection at runtime No caching, reflection on every call Learning projects, small scripts, understanding the mechanics
PHP-DI Yes, including attribute support Compiled container cache Framework-agnostic projects with performance requirements
Symfony DependencyInjection Yes, including compiler pass Compiles to optimized PHP code Large applications with complex service graphs
Laravel container Yes, including contextual bindings Good, partly with cache Laravel projects and closely related packages

The comparison shows: a homegrown DI container is didactically valuable and usable for small, controlled codebases, but it does not replace a mature container in production environments with high demands on performance and configuration convenience. Yet anyone who has built the mechanics themselves once reads the source code of PHP-DI or Symfony DependencyInjection with entirely different eyes, every optimization and every additional feature can be understood as an answer to a concrete problem they have already solved themselves.

10. Summary

A DI container can be assembled from a handful of clearly separated building blocks: the lean PSR-11 interface with get() and has(), reflection-based autowiring that analyzes constructor parameters at runtime, a resolution stack to detect cycles, a binding table for interface-to-implementation mappings, and closures as factories for anything autowiring cannot resolve automatically. Together these building blocks form a fully functional, if deliberately minimal, DI container in well under 200 lines of PHP 8.4 code.

The real payoff is not in putting this homegrown version into production, but in the understanding it provides: anyone who has traced for themselves how a DI container reads constructor parameters via reflection, detects circular dependencies, and distinguishes singleton from transient bindings no longer sees the configuration options of PHP-DI, Symfony DependencyInjection, and the Laravel container as a magic black box, but as concrete answers to problems that became visible in the homegrown build itself.

Building a DI Container From Scratch, the Essentials at a Glance

PSR-11 interface

get() and has(), completed by NotFoundExceptionInterface and ContainerExceptionInterface for error cases.

Autowiring via reflection

ReflectionClass and ReflectionParameter analyze constructors at runtime and resolve type hints recursively.

Cycle detection

A resolution stack prevents infinite loops on circular dependencies and throws a clear error message.

Bindings & factories

Interface-to-implementation mappings, singletons, closures, and parameter overrides for anything autowiring cannot solve.

11. FAQ: Building a DI Container From Scratch and Understanding PSR-11

1What is a DI container?
A registry that knows how to build objects with their dependencies and assembles them automatically at runtime, instead of manually wiring constructor calls.
2Difference from a service locator?
DI injects dependencies from the outside via the constructor. A service locator is actively asked from inside the class, hides real dependencies, and counts as an anti-pattern.
3What does PSR-11 mandate?
Only get(string $id) and has(string $id), plus two exception types. Bindings, singletons, and autowiring are deliberately left as implementation details.
4How does autowiring via reflection work?
ReflectionClass reads the constructor, every ReflectionParameter is checked, class-typed parameters are recursively requested from the container itself.
5How do you detect circular dependencies?
Via a resolution stack. If an identifier reappears before being removed, a cycle exists and the container immediately throws an exception.
6Singleton vs. transient binding?
Transient creates a new instance on every get() call. Singleton is built once and afterward returned from the container's cache.
7When do you need parameter overrides?
When a constructor parameter has a primitive type like string or int that reflection cannot resolve automatically. The override maps a specific value.
8Can the homegrown version read #[Inject]?
In principle yes, via ReflectionParameter::getAttributes(). The MinimalContainer shown deliberately skips this to keep the core mechanics simple.
9When switch to PHP-DI or Symfony?
As soon as performance under load, compilation, attribute configuration, or a large integration ecosystem is needed.
10Suitable for real projects?
Not really in production, established containers are more reliable and performant there. As a learning project to understand the mechanics, it is very valuable.

Mironsoft

PHP architecture, legacy refactoring and dependency injection consulting

A DI container that fits your architecture?

We analyze existing PHP codebases, uncover hidden service-locator patterns, and bring clean dependency injection with the right DI container, whether PHP-DI, Symfony DependencyInjection, or a tailored approach, into your application.

Architecture review

Analysis of existing DI container configuration and uncovering of service-locator anti-patterns

Container migration

Switching between PHP-DI, Symfony DependencyInjection, and the Laravel container with no downtime

Training & pairing

Hands-on workshops on dependency injection, autowiring, and PSR standards for your team