Einen minimalen DI-Container selbst bauen: PSR-11 verstehen
AI generated
<?php
8.4
PHP · PSR-11 · Dependency Injection · OOP
Einen minimalen DI-Container selbst bauen
um PSR-11 wirklich zu verstehen

Wer einen DI-Container nur aus der Dokumentation eines Frameworks kennt, versteht selten, warum er so entworfen ist, wie er entworfen ist. In diesem Artikel bauen wir einen minimalen, PSR-11-kompatiblen DI-Container von Grund auf in reinem PHP 8.4: mit Reflection-basiertem Autowiring, rekursiver Abhängigkeitsauflösung, Erkennung von Zirkularität und klar definierten Bindings zwischen Interface und Implementierung. Am Ende steht kein Produktionscode, sondern ein Werkzeug zum Verstehen, das zeigt, was PHP-DI, Symfony DependencyInjection und der Laravel Container tatsächlich unter der Haube leisten.

18 Min. Lesezeit PSR-11 · Reflection · Autowiring · Bindings PHP 8.4 · strict_types · Framework-unabhängig

1. Warum PSR-11 und was ein DI-Container wirklich leistet

Ein DI-Container ist im Kern nichts anderes als eine Registry, die weiß, wie Objekte erzeugt werden, und diese Objekte samt ihrer Abhängigkeiten automatisch zusammenbaut. Sobald eine Anwendung mehr als eine Handvoll Klassen umfasst, wird das manuelle Verdrahten von Konstruktoraufrufen mühsam und fehleranfällig: Jede neue Abhängigkeit einer Klasse zieht Änderungen an jeder Stelle nach sich, an der diese Klasse instanziiert wird. Ein DI-Container löst genau dieses Problem, indem er den kompletten Objektgraphen zur Laufzeit auflöst, statt dass Entwickler ihn von Hand zusammensetzen.

Wichtig ist die Abgrenzung zum Service Locator, einem verwandten, aber fundamental anderen Muster. Ein Service Locator wird von innerhalb einer Klasse aktiv nach einer Abhängigkeit gefragt ($locator->get(Logger::class) irgendwo im Methodenkörper), wodurch die Klasse implizit vom gesamten Locator abhängig wird und ihre echten Abhängigkeiten im Konstruktor verschleiert. Ein DI-Container hingegen injiziert Abhängigkeiten von außen, meist über den Konstruktor, sodass jede Klasse ihre Anforderungen explizit in ihrer Signatur offenlegt. PSR-11 standardisiert genau die Grenze zwischen beiden Welten: Das Interface ContainerInterface beschreibt, wie man an ein Objekt herankommt, sagt aber nichts darüber aus, wie eine Klasse intern gebaut wird, das bleibt Aufgabe des Containers selbst.

2. Das minimale Interface: ContainerInterface mit get() und has()

PSR-11 definiert absichtlich ein extrem schlankes Interface für einen DI-Container: nur zwei Methoden, get(string $id): mixed und has(string $id): bool. get() liefert eine Instanz für den übergebenen Bezeichner oder wirft eine Ausnahme, wenn nichts gefunden wird oder die Auflösung fehlschlägt. has() beantwortet nur die Frage, ob der Container theoretisch etwas für diesen Bezeichner liefern kann, garantiert aber ausdrücklich nicht, dass ein nachfolgender get()-Aufruf erfolgreich ist. Diese bewusste Schwäche der Zusicherung erlaubt es Containern, has() günstig zu implementieren, ohne den vollen Auflösungsprozess durchzuspielen.

Zwei Ausnahmetypen ergänzen das Interface: NotFoundExceptionInterface für den Fall, dass der Bezeichner dem Container komplett unbekannt ist, und ContainerExceptionInterface als allgemeinerer Fehlertyp für alle anderen Probleme während der Auflösung, etwa eine fehlgeschlagene Instanziierung. Jede konkrete Ausnahmeklasse eines Containers muss zusätzlich von einer echten Exception-Klasse erben, denn diese PSR-Interfaces selbst erweitern keine ausführbare Basisklasse. Der folgende Code zeigt das vollständige PSR-11-Interface, wie es das Paket psr/container definiert.


<?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;
}

Diese drei Interfaces sind alles, was PSR-11 vorschreibt. Kein bind(), kein singleton(), keine Konfigurationssyntax, denn das Wie der Bindung ist explizit Implementierungsdetail und nicht Teil des Standards. Genau diese Kleinheit macht PSR-11 zum idealen Startpunkt, um selbst einen DI-Container zu bauen: Man muss nur zwei Methoden implementieren, um mit jedem PSR-11-kompatiblen Framework kompatibel zu sein.

3. Autowiring per Reflection: Konstruktor-Analyse zur Laufzeit

Das Herzstück jedes modernen DI-Containers ist Autowiring: die Fähigkeit, eine Klasse zu instanziieren, ohne dass jemand ihre Abhängigkeiten manuell aufgelistet hat. Möglich wird das durch PHPs Reflection-API. ReflectionClass öffnet eine Klasse zur Laufzeit für Introspektion, getConstructor() liefert den Konstruktor als ReflectionMethod, und getParameters() liefert ein Array von ReflectionParameter-Objekten, aus denen sich Name, Typ und ein eventuell vorhandener Standardwert jedes Parameters auslesen lassen.

Für jeden Parameter prüft der DI-Container, ob ein Typ-Hint vorhanden und kein primitiver Typ ist, ReflectionNamedType::isBuiltin() unterscheidet zwischen Klassen beziehungsweise Interfaces und Skalartypen wie string oder int. Ist der Typ eine Klasse oder ein Interface, fragt der Container rekursiv sich selbst nach dieser Abhängigkeit, was letztlich zu einer Kaskade von get()-Aufrufen führt, die den kompletten Objektgraphen von den Blättern bis zur Wurzel aufbaut. Gibt es keinen Typ-Hint oder handelt es sich um einen primitiven Typ, greift der Container auf einen Standardwert zurück, sofern einer im Konstruktor definiert ist, andernfalls muss die Abhängigkeit explizit konfiguriert werden, dazu mehr in den Abschnitten zu Bindings und 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.",
        );
    }
}

Diese Form von Autowiring funktioniert vollständig ohne Konfigurationsdatei, solange alle Abhängigkeiten über klar typisierte Konstruktorparameter injiziert werden, genau das Muster, das Constructor Property Promotion in PHP 8.4 idiomatisch macht. Ein DI-Container, der ausschließlich auf Autowiring setzt, kommt bei sauber typisierten Klassenhierarchien mit minimalem Konfigurationsaufwand aus.

4. Rekursive Auflösung von Abhängigkeiten und Erkennung von Zirkularität

Sobald ein DI-Container Abhängigkeiten rekursiv auflöst, entsteht ein neues Risiko: zirkuläre Abhängigkeiten. Klasse A verlangt im Konstruktor eine Instanz von B, B wiederum verlangt eine Instanz von A. Ohne Schutzmechanismus ruft der Container get(A::class) auf, das ruft get(B::class) auf, das wiederum get(A::class), und so weiter, bis der PHP-Prozess mit einem Stack-Overflow-Fehler abstürzt, meist mit einer wenig hilfreichen Fehlermeldung.

Die Lösung ist ein Resolution-Stack: ein einfaches Array, das während der Auflösung jeden aktuell in Bearbeitung befindlichen Bezeichner enthält. Bevor der DI-Container eine neue Klasse auflöst, prüft er, ob deren Bezeichner bereits auf dem Stack liegt. Ist das der Fall, liegt zweifelsfrei eine Zirkularität vor, und der Container kann sofort eine aussagekräftige Ausnahme werfen, die den kompletten Auflösungspfad zeigt, statt den Prozess in einer Endlosschleife verhungern zu lassen. Nach jeder Auflösung, egal ob erfolgreich oder mit Ausnahme beendet, muss der Bezeichner wieder vom Stack entfernt werden, ein klassischer Fall für einen 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);
        }
    }
}

Zirkuläre Abhängigkeiten sind fast immer ein Zeichen für ein Architekturproblem, nicht für einen fehlenden Container-Trick. Wenn zwei Klassen sich gegenseitig brauchen, hilft in der Regel die Extraktion einer dritten, gemeinsamen Abstraktion oder der Wechsel zu einer optionalen Methodenabhängigkeit statt einer Konstruktorabhängigkeit. Ein guter DI-Container deckt das Problem früh und mit klarer Fehlermeldung auf, statt es zu verschleiern.

5. Bindings definieren: Interface zu konkreter Implementierung, Singleton vs. transiente Factory

Reines Autowiring reicht nicht, sobald eine Klasse von einem Interface statt von einer konkreten Klasse abhängt, was in jeder Codebasis, die auf Service Contracts setzt, der Normalfall ist. Reflection kann nicht wissen, welche konkrete Implementierung eines Interfaces gemeint ist, denn ein Interface hat keinen instanziierbaren Konstruktor. Genau hier kommt die Bindungstabelle des DI-Containers ins Spiel: eine einfache Zuordnung von abstraktem Bezeichner, meist ein Interface-Name, zu konkretem Klassennamen, typischerweise über eine Methode wie bind(string $abstract, string $concrete): void konfiguriert.

Die zweite wichtige Dimension ist der Lebenszyklus einer aufgelösten Instanz. Ein transienter Bezeichner erzeugt bei jedem get()-Aufruf eine neue Instanz, das ist die sichere Voreinstellung für zustandslose Services. Ein als Singleton markierter Bezeichner hingegen wird nur beim ersten Aufruf gebaut, danach liefert der DI-Container bei jedem weiteren get()-Aufruf dieselbe zwischengespeicherte Instanz zurück. Datenbankverbindungen, Logger und Konfigurationsobjekte sind klassische Kandidaten für Singleton-Bindings, während Value Objects und kurzlebige Command-Handler in aller Regel transient bleiben sollten, um unerwartetes geteiltes Verhalten zu vermeiden.

6. Closures als Factories registrieren, Parameter-Overrides für Primitive

Nicht jede Abhängigkeit lässt sich sinnvoll per Autowiring bauen. Manche Klassen benötigen komplexe Initialisierungslogik, eine Verbindung zu einer externen Ressource oder Werte, die zur Laufzeit aus der Umgebung stammen, etwa ein API-Schlüssel oder ein Datenbank-DSN. Für diese Fälle erlaubt ein flexibler DI-Container die Registrierung einer Closure als Factory: eine anonyme Funktion, die bei Bedarf aufgerufen wird und typischerweise den Container selbst als Parameter erhält, um bei Bedarf weitere Abhängigkeiten nachzuladen.

Eng verwandt ist das Problem primitiver Konstruktorparameter. Reflection kann einen Parameter vom Typ string oder int nicht automatisch auflösen, denn es gibt unendlich viele mögliche Strings und Integer, und keiner von ihnen ist per Typ-Hint eindeutig. Ein DI-Container braucht daher einen Mechanismus für Parameter-Overrides: eine gezielte Zuordnung von Klassenname und Parametername zu einem konkreten Wert, die bei der Parameterauflösung Vorrang vor Autowiring und Standardwerten erhält. So lässt sich zum Beispiel ein Timeout-Wert oder ein Verzeichnispfad an genau die Klasse injizieren, die ihn benötigt, ohne dass jede andere Klasse mit einem gleichnamigen Parameter davon betroffen wäre.


<?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 als Factories und Parameter-Overrides ergänzen sich: Für einfache Primitive genügt ein Override, für komplexe Objekte mit Seiteneffekten bei der Erzeugung ist eine Factory-Closure die sauberere Wahl. Beide Mechanismen behalten den zentralen Vorteil eines DI-Containers: Die konsumierende Klasse weiß nichts von der Herkunft ihrer Abhängigkeiten, sie deklariert nur, was sie im Konstruktor braucht.

7. Der vollständige Code: eine MinimalContainer-Klasse in PHP 8.4

Alle bisher besprochenen Bausteine, PSR-11-Interface, Reflection-Autowiring, Zirkularitätserkennung, Bindings und Factories, lassen sich in einer einzigen, überschaubaren Klasse zusammenführen. Der folgende DI-Container nutzt konsequent PHP-8.4-Syntax: declare(strict_types=1), Constructor Property Promotion für die Konfiguration des Containers selbst und readonly dort, wo sich ein Wert nach der Konstruktion nicht mehr ändern soll. Die Klasse ist bewusst kompakt gehalten, deutlich unter 150 Zeilen Code für ein voll funktionsfähiges Autowiring mit Bindings, Singletons, Factories und Cycle Detection.

Wichtig ist, was diese Implementierung bewusst weglässt: keine Attribute-basierte Konfiguration, kein Caching kompilierter Definitionen, keine Unterstützung für Variadic-Parameter oder Union-Types in Konstruktoren. Das ist Absicht, denn Ziel dieses DI-Containers ist nicht der Produktiveinsatz, sondern das Verständnis der Mechanik, die auch hinter komplexeren Containern steckt.


<?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. Grenzen des Eigenbaus: Performance bei großen Objektgraphen, Attribute, wann ein reifer Container sinnvoller ist

Der hier gebaute DI-Container löst bei jedem get()-Aufruf für eine nicht zwischengespeicherte Instanz die komplette Reflection-Analyse neu auf: ReflectionClass und ReflectionParameter werden bei jedem Aufruf frisch erzeugt. Bei kleinen Objektgraphen mit wenigen Dutzend Klassen ist das kein spürbares Problem, bei großen Anwendungen mit hunderten Services und tiefen Abhängigkeitsketten summiert sich der Reflection-Overhead jedoch messbar, insbesondere bei jedem HTTP-Request in einer klassischen PHP-Anfrage-Antwort-Architektur ohne persistenten Worker-Prozess.

Reife Container wie PHP-DI kompilieren die Auflösungslogik einmalig in optimierten PHP-Code, der bei nachfolgenden Aufrufen ohne Reflection auskommt, ein Ansatz, den Symfony DependencyInjection mit seinem Container-Compiler-Pass ebenfalls verfolgt. Zusätzlich unterstützen etablierte Container Attribute-basierte Konfiguration, etwa #[Inject] oder #[Autowire], mit denen sich Bindings direkt an der Klasse annotieren lassen, statt sie zentral in einer Konfigurationsdatei zu pflegen. Für ein kleines Skript, ein Lernprojekt oder eine bewusst minimalistische Bibliothek bleibt ein selbstgebauter DI-Container völlig ausreichend, sobald aber Performance unter Last, Attribute-Konfiguration, Kompilierung oder ein großes Ökosystem an Integrationen gefragt sind, ist der Wechsel zu PHP-DI, Symfony DependencyInjection oder dem Laravel Container die wirtschaftlichere Entscheidung.

9. Eigenbau-Container im direkten Vergleich zu etablierten Containern

Die folgende Tabelle stellt den in diesem Artikel gebauten DI-Container den drei bekanntesten PHP-Containern gegenüber. Keiner der etablierten Container ist zwingend an ein bestimmtes Framework gebunden: PHP-DI lässt sich in jedem beliebigen PHP-Projekt einsetzen, Symfony DependencyInjection wird auch außerhalb des vollen Symfony-Frameworks genutzt, und der Laravel Container funktioniert grundsätzlich als eigenständiges Composer-Paket.

Ansatz Autowiring Performance Wann verwenden
Eigenbau-Container Ja, per Reflection zur Laufzeit Kein Caching, Reflection bei jedem Aufruf Lernprojekte, kleine Skripte, Verständnis der Mechanik
PHP-DI Ja, inklusive Attribute-Unterstützung Kompilierter Container-Cache Framework-unabhängige Projekte mit Performance-Anspruch
Symfony DependencyInjection Ja, inklusive Compiler Pass Kompiliert zu optimiertem PHP-Code Große Anwendungen mit komplexen Service-Graphen
Laravel Container Ja, inklusive kontextueller Bindungen Gut, teils mit Cache Laravel-Projekte und eng verwandte Pakete

Der Vergleich zeigt: Ein selbstgebauter DI-Container ist didaktisch wertvoll und für kleine, kontrollierte Codebasen brauchbar, aber er ersetzt keinen reifen Container in Produktionsumgebungen mit hohen Anforderungen an Performance und Konfigurationskomfort. Wer die Mechanik einmal selbst gebaut hat, liest den Quellcode von PHP-DI oder Symfony DependencyInjection jedoch mit völlig anderen Augen, jede Optimierung und jedes zusätzliche Feature lässt sich als Antwort auf ein konkretes Problem einordnen, das man selbst schon gelöst hat.

10. Zusammenfassung

Ein DI-Container lässt sich aus wenigen klar abgegrenzten Bausteinen zusammensetzen: dem schlanken PSR-11-Interface mit get() und has(), Reflection-basiertem Autowiring, das Konstruktorparameter zur Laufzeit analysiert, einem Resolution-Stack zur Erkennung von Zirkularität, einer Bindungstabelle für Interface-zu-Implementierung-Zuordnungen und Closures als Factories für alles, was Autowiring nicht automatisch auflösen kann. Zusammen ergeben diese Bausteine einen voll funktionsfähigen, wenn auch bewusst minimalen DI-Container in deutlich unter 200 Zeilen PHP-8.4-Code.

Der eigentliche Gewinn liegt nicht im produktiven Einsatz dieses Eigenbaus, sondern im Verständnis, das er vermittelt: Wer einmal selbst nachvollzogen hat, wie ein DI-Container Konstruktorparameter per Reflection ausliest, zirkuläre Abhängigkeiten erkennt und Singleton- von transienten Bindings unterscheidet, versteht die Konfigurationsoptionen von PHP-DI, Symfony DependencyInjection und dem Laravel Container nicht mehr als magische Blackbox, sondern als konkrete Antworten auf Probleme, die im Eigenbau selbst sichtbar wurden.

DI-Container selbst bauen - Das Wichtigste auf einen Blick

PSR-11-Interface

get() und has(), ergänzt um NotFoundExceptionInterface und ContainerExceptionInterface für Fehlerfälle.

Autowiring per Reflection

ReflectionClass und ReflectionParameter analysieren Konstruktoren zur Laufzeit und lösen Typ-Hints rekursiv auf.

Cycle Detection

Ein Resolution-Stack verhindert Endlosschleifen bei zirkulären Abhängigkeiten und wirft eine klare Fehlermeldung.

Bindings & Factories

Interface-zu-Implementierung-Zuordnungen, Singletons, Closures und Parameter-Overrides für alles, was Autowiring nicht löst.

11. FAQ: DI-Container selbst bauen und PSR-11 verstehen

1Was ist ein DI-Container?
Eine Registry, die weiß, wie Objekte samt Abhängigkeiten gebaut werden, und diese automatisch zur Laufzeit zusammensetzt, statt manuellem Verdrahten von Konstruktoraufrufen.
2Unterschied zu einem Service Locator?
DI injiziert Abhängigkeiten von außen über den Konstruktor. Ein Service Locator wird von innerhalb der Klasse aktiv gefragt, verschleiert echte Abhängigkeiten und gilt als Anti-Pattern.
3Was schreibt PSR-11 vor?
Nur get(string $id) und has(string $id) sowie zwei Ausnahmetypen. Bindings, Singletons und Autowiring bleiben bewusst Implementierungsdetail.
4Wie funktioniert Autowiring per Reflection?
ReflectionClass liest den Konstruktor aus, jeder ReflectionParameter wird geprüft, klassengetypte Parameter werden rekursiv beim Container selbst angefragt.
5Wie erkennt man zirkuläre Abhängigkeiten?
Über einen Resolution-Stack. Taucht ein Bezeichner erneut auf, bevor er entfernt wurde, liegt eine Zirkularität vor und der Container wirft sofort eine Ausnahme.
6Singleton vs. transiente Bindung?
Transient erzeugt bei jedem get()-Aufruf eine neue Instanz. Singleton wird nur einmal gebaut und danach aus dem Cache des Containers zurückgegeben.
7Wann braucht man Parameter-Overrides?
Wenn ein Konstruktorparameter einen primitiven Typ wie string oder int hat, den Reflection nicht automatisch auflösen kann. Der Override ordnet gezielt einen Wert zu.
8Kann der Eigenbau Attribute wie #[Inject] lesen?
Grundsätzlich ja, über ReflectionParameter::getAttributes(). Der gezeigte MinimalContainer verzichtet bewusst darauf, um die Kernmechanik einfach zu halten.
9Wann zu PHP-DI oder Symfony wechseln?
Sobald Performance unter Last, Kompilierung, Attribute-Konfiguration oder ein großes Integrations-Ökosystem gebraucht werden.
10Für reale Projekte geeignet?
Produktiv eher nicht, etablierte Container sind zuverlässiger und performanter. Als Lernprojekt zum Verständnis der Mechanik ist der Eigenbau sehr wertvoll.

Mironsoft

PHP-Architektur, Legacy-Refactoring und Dependency-Injection-Beratung

Ein DI-Container, der zu eurer Architektur passt?

Wir analysieren bestehende PHP-Codebasen, decken verdeckte Service-Locator-Muster auf und bringen sauberes Dependency Injection mit dem passenden DI-Container, ob PHP-DI, Symfony DependencyInjection oder ein maßgeschneiderter Ansatz, in eure Anwendung.

Architektur-Review

Analyse bestehender DI-Container-Konfiguration und Aufdeckung von Service-Locator-Antipatterns

Container-Migration

Wechsel zwischen PHP-DI, Symfony DependencyInjection und Laravel Container ohne Downtime

Schulung & Pairing

Hands-on-Workshops zu Dependency Injection, Autowiring und PSR-Standards für euer Team