Sauber testbare und wartbare Klassen entstehen selten von allein. Ein Muster, das dabei besonders viel bewirkt, ist die Auslagerung von Abhängigkeiten nach außen, statt sie in der Klasse selbst zu erzeugen. Dieses Tutorial zeigt, wie Dependency Injection in PHP funktioniert, von der Constructor Injection bis zum eigenen Container nach PSR-11.
Was ist PHP Dependency Injection?
Wer in einer PHP-Anwendung saubere und testbare Klassen schreiben möchte, kommt an PHP Dependency Injection nicht vorbei. Das Pattern beschreibt ein einfaches Prinzip: Eine Klasse erzeugt ihre Abhängigkeiten nicht selbst, sondern bekommt das benötigte Objekt von außen geliefert. Statt im Service mitten im Code new PDO(...) aufzurufen, erhält der Service die fertige Datenbank-Verbindung im Konstruktor übergeben. Damit wird die Klasse von einer konkreten Implementierung entkoppelt, leichter testbar und im Code-Review klarer lesbar.

Bevor Constructor Injection, Setter Injection und der eigene PSR-11-Container im Detail folgen, eine wichtige Abgrenzung zu einem oft verwechselten Begriff.
PHP Dependency Injection ist ausdrücklich ein OOP-Pattern und nicht zu verwechseln mit dem Paket-Manager Composer, der externe Bibliotheken verwaltet. In diesem Tutorial geht es ausschließlich um Dependency Injection als Konstruktionsmuster für Objekte, also um Constructor Injection, Setter Injection und einen schlanken DI-Container, der die Verdrahtung übernimmt.
Inversion of Control: das Prinzip hinter PHP Dependency Injection
Bevor es in den Code geht, lohnt sich ein Blick auf den übergeordneten Begriff. Inversion of Control (IoC) beschreibt die Umkehrung der Aufrufrichtung: nicht die Klasse holt ihre Abhängigkeiten, sondern eine äussere Instanz steuert, was die Klasse bekommt. PHP Dependency Injection ist eine konkrete Implementierung dieses Prinzips. Andere Spielarten von IoC sind Event-Listener-Systeme oder Template-Methoden im Framework-Kontext, in denen das Framework den Lebenszyklus diktiert und der eigene Code nur an klar definierten Stellen einklinkt.
In der klassischen Variante ohne IoC weiss eine Klasse genau, wie ihre Abhängigkeiten zu erzeugen sind, und kontrolliert die Reihenfolge selbst. Mit IoC wird diese Kontrolle abgegeben, der Container oder ein anderes äusseres System übernimmt sie. Das Resultat ist eine Klasse, die nur noch ihren eigenen Job kennt und für alles andere auf gelieferte Bausteine vertraut. Genau diese Trennung ist es, die DI so wertvoll für Tests und für austauschbare Implementierungen macht.
Constructor Injection als Standard
Die wichtigste Form der PHP Dependency Injection ist die Constructor Injection. Pflicht-Abhängigkeiten werden im Konstruktor entgegengenommen und dort als typisierte Eigenschaften abgelegt. Mit der Constructor Promotion seit PHP 8 wird die Schreibweise besonders kompakt.
<?php
final class OrderService
{
public function __construct(private PDO $db) {}
public function place(int $orderId): void
{
$stmt = $this->db->prepare('INSERT INTO orders (id) VALUES (?)');
$stmt->execute([$orderId]);
}
}
$db = new PDO('mysql:host=localhost;dbname=shop', 'user', 'pass');
$service = new OrderService($db);
$service->place(42);
Im Vergleich zur klassischen Variante, in der der Service seine PDO-Verbindung selbst erzeugt, ist die Sache jetzt eindeutig: Wer den Service nutzt, sieht im Konstruktor sofort, dass eine PDO-Instanz nötig ist. Im Test wird stattdessen ein Mock oder eine SQLite-In-Memory-Verbindung übergeben, ohne dass der Service-Code geändert werden muss.
Setter Injection für optionale Abhängigkeiten
Nicht jede Abhängigkeit ist Pflicht. Ein Logger, der nur bei aktivem Debug-Modus mitschreibt, oder eine Cache-Schnittstelle, die optional ist, gehört nicht zwingend in den Konstruktor. Für solche Fälle eignet sich die Setter Injection. Sie macht klar erkennbar, dass die Abhängigkeit optional ist.
<?php
final class OrderService
{
private ?Logger $logger = null;
public function __construct(private PDO $db) {}
public function setLogger(Logger $logger): void
{
$this->logger = $logger;
}
public function place(int $orderId): void
{
$this->db->prepare('INSERT INTO orders (id) VALUES (?)')->execute([$orderId]);
$this->logger?->info("Bestellung $orderId erfasst");
}
}
Der ?->-Operator ist der Nullsafe-Operator, der seit PHP 8 erlaubt, eine Methode auf einem möglicherweise null-Wert sicher aufzurufen. Wenn kein Logger gesetzt ist, passiert nichts. Wer Setter Injection einsetzt, sollte aber sparsam damit umgehen: Pflicht-Abhängigkeiten gehören immer in den Konstruktor.
Ein eigener kleiner DI-Container für PHP Dependency Injection
Sobald eine Anwendung mehrere Services mit Abhängigkeiten hat, wird die manuelle Verdrahtung im Bootstrap mühsam und ein zentraler Container für PHP Dependency Injection wird zur natürlichen Lösung. Ein DI-Container übernimmt diese Aufgabe. Er kennt für jeden Service eine Factory-Funktion und liefert auf Anfrage entweder eine vorhandene Instanz oder erzeugt eine neue.
<?php
final class Container
{
private array $factories = [];
private array $instances = [];
public function set(string $id, callable $factory): void
{
$this->factories[$id] = $factory;
}
public function has(string $id): bool
{
return isset($this->factories[$id]);
}
public function get(string $id): mixed
{
if (!isset($this->instances[$id])) {
if (!isset($this->factories[$id])) {
throw new RuntimeException("Service '$id' nicht definiert");
}
$this->instances[$id] = ($this->factories[$id])($this);
}
return $this->instances[$id];
}
}
$c = new Container();
$c->set(PDO::class, fn() => new PDO('mysql:host=localhost;dbname=shop', 'u', 'p'));
$c->set(OrderService::class, fn(Container $c) => new OrderService($c->get(PDO::class)));
$service = $c->get(OrderService::class);
Der Container hält eine Service-Definition in $factories und cached die fertige Instanz in $instances. Damit verhält sich jede Service-Definition wie ein Singleton im positiven Sinn: pro Lebenszyklus existiert genau eine Instanz, ohne dass dafür ein klassisches Singleton-Pattern nötig wäre.
flowchart TD
A[Bootstrap erstellt Container] --> B[Container.get OrderService]
B --> C{Instanz vorhanden?}
C -->|Nein| D[Factory ausfuehren]
D --> E[Abhaengigkeiten aus Container holen]
E --> F[OrderService instanziieren]
F --> G[Im Container speichern]
G --> H[OrderService zurueckgeben]
C -->|Ja| H
PSR-11 als Standard für Container-Interoperabilität
Damit verschiedene Container-Implementierungen austauschbar bleiben, hat die PHP Framework Interop Group das Psr\Container\ContainerInterface standardisiert. Es definiert genau die zwei Methoden get() und has(). Wer seinen eigenen Container an dieses Interface anpasst, ist mit allen PSR-11-fähigen Bibliotheken kompatibel.
In der Praxis nutzt fast jedes PHP-Framework einen Dependency Injection Container im Hintergrund. Symfony, Laravel, Slim und die meisten Microframeworks halten sich an die PSR-11-Norm. Eine besonders bekannte eigenständige Bibliothek ist PHP-DI, die Konstruktoren automatisch per Reflection auflöst und eine annotationsbasierte Konfiguration mitbringt. Das hat den Vorteil, dass Service-Provider und Pakete unabhängig vom konkreten Container geschrieben werden können und mehrere Objekte mit ihren Abhängigkeiten zentral verdrahtet werden. Wer ein eigenes Plugin oder Modul baut, sollte sich daher von Anfang an gegen das ContainerInterface programmieren und keine konkrete Container-Klasse als Parameter erwarten. Damit bleibt der Code portabel und kann in unterschiedlichsten Frameworks eingesetzt werden, ohne die DI-Verdrahtung neu zu schreiben.
PHP-DI als fertige Library
Wer keinen eigenen Container schreiben möchte, greift zu einer ausgereiften Bibliothek. PHP-DI ist dabei der bekannteste Einzelkandidat im PHP-Ökosystem. Die Installation erfolgt per Composer, danach reicht oft eine einzige Container-Definition, weil PHP-DI über Reflection automatisch erkennt, welche Konstruktor-Argumente eine Klasse erwartet.
<?php
/* composer require php-di/php-di */
use DI\ContainerBuilder;
$builder = new ContainerBuilder();
$builder->addDefinitions([
PDO::class => fn() => new PDO('mysql:host=localhost;dbname=shop', 'u', 'p'),
/* Logger als Interface auf konkrete Implementierung mappen */
LoggerInterface::class => DI\create(FileLogger::class)
->constructor('/var/log/app.log'),
]);
$container = $builder->build();
/* Autowiring: PHP-DI liest den Konstruktor von OrderService per Reflection
* und holt die noetigen Abhaengigkeiten selbst aus dem Container */
$service = $container->get(OrderService::class);
$service->place(42);
PHP-DI bringt drei Stärken mit, die im Eigenbau viel Arbeit kosten würden: Autowiring per Reflection, Attribut-basierte Konfiguration (#[Inject]) und Compilation des Containers für Production. Letzteres ist ein wichtiges Detail. Im Default arbeitet PHP-DI bei jedem Request mit Reflection, was im Microsekunden-Bereich kostet. Für Production lässt sich der Container per enableCompilation() in eine fertige PHP-Klasse vorberechnen, die ohne Reflection auskommt und dadurch deutlich schneller startet.
Symfony Container und Laravel Container im Vergleich
Die grossen Frameworks bringen ihre eigenen DI-Container mit, beide PSR-11-konform und beide mit Autowiring. Der Symfony DependencyInjection Component konfiguriert Services typischerweise per YAML, XML oder PHP-Datei. Per Default ist Autowiring an, expliziter Setup ist nur nötig, wenn mehrere Implementierungen eines Interfaces nebeneinander existieren.
<?php
/* config/services.yaml in Symfony */
/*
services:
_defaults:
autowire: true # Autowiring per Reflection
autoconfigure: true # Tags wie kernel.event_listener automatisch
App\Service\OrderService: ~
App\Logger\LoggerInterface: '@App\Logger\FileLogger'
*/
/* In einem Controller wird der Service per Type-Hint injiziert */
final class OrderController
{
public function __construct(private OrderService $orders) {}
public function place(int $id): Response
{
$this->orders->place($id);
return new Response('OK');
}
}
Der Laravel Service Container geht den umgekehrten Weg und konfiguriert Bindings per Code in einem ServiceProvider. Auch Laravel löst Konstruktor-Argumente per Reflection auf, daher wird für konkrete Klassen oft überhaupt keine Definition gebraucht. Spannend werden Bindings nur dann, wenn ein Interface auf eine konkrete Klasse gemappt werden soll oder wenn der Container ein Singleton zurückgeben soll.
<?php
/* app/Providers/AppServiceProvider.php in Laravel */
final class AppServiceProvider extends ServiceProvider
{
public function register(): void
{
/* Interface auf Implementierung binden */
$this->app->bind(LoggerInterface::class, FileLogger::class);
/* Singleton: nur eine Instanz pro Request */
$this->app->singleton(PDO::class, fn() => new PDO(
'mysql:host=localhost;dbname=shop', 'u', 'p'
));
}
}
/* Im Controller wieder per Type-Hint */
final class OrderController extends Controller
{
public function __construct(private OrderService $orders) {}
}
Die Konzepte sind also identisch: PSR-11-Container, Autowiring, Singleton- vs. Transient-Lifecycle, Interface-zu-Klasse-Mapping. Symfony favorisiert deklarative Konfig per YAML, Laravel setzt auf Programmatik per Provider. Wer ein Framework wählt, akzeptiert dessen Konfig-Stil. Wer ein Standalone-Projekt baut, wählt PHP-DI als framework-unabhängigen Container und gewinnt fast die gleiche Funktionalität.
Service Locator vs. Dependency Injection
Bevor das Service-Locator-Antipattern im Detail folgt, lohnt der direkte Begriffs-Vergleich. Beide Ansätze nutzen denselben Container, unterscheiden sich aber fundamental darin, wer den Container kennt.
| Aspekt | Dependency Injection | Service Locator |
| Wer kennt den Container | nur Bootstrap und Factories | jede Service-Klasse |
| Sichtbare Abhängigkeiten | im Konstruktor klar typisiert | versteckt in Methoden-Aufrufen |
| Test-Setup | Mocks direkt übergeben | Container muss aufgebaut werden |
| Type-Hints | Interface oder Klasse | nur Container |
| Refactoring | IDE findet Verwender per Type-Hint | Container-Aufrufe schwer auffindbar |
Die Konsequenz: Wer Dependency Injection sauber durchzieht, sieht im Konstruktor jeder Klasse, was sie braucht. Wer Service Locator nutzt, hat einen Container als magische Black-Box, der versteckte Abhängigkeiten freischaltet. Beides funktioniert technisch, aber nur DI bleibt bei wachsender Codebasis übersichtlich. Im nächsten Abschnitt folgt ein konkretes Code-Beispiel, das den Antipattern-Charakter sichtbar macht.
Service Locator als Antipattern
Auf den ersten Blick wirkt es elegant, einfach den Container in jeden Service zu übergeben und ihn dort nach Belieben zu fragen. Genau das ist das berüchtigte Service-Locator-Antipattern. Es bringt das Hidden-Dependencies-Problem zurück, das Dependency Injection eigentlich lösen wollte.
<?php
final class OrderService
{
public function __construct(private Container $container) {}
public function place(int $orderId): void
{
/* Antipattern: der Service kennt den Container und holt
sich Dinge raus. Niemand sieht von aussen, welche
Abhaengigkeiten OrderService wirklich braucht.
Ausserdem muss im Test der gesamte Container aufgebaut
werden, statt nur die noetigen Mocks zu uebergeben */
$db = $this->container->get(PDO::class);
$db->exec("INSERT INTO orders (id) VALUES ($orderId)");
}
}
Die Faustregel ist klar: Der Container wird im Bootstrap aufgebaut und löst dort die Abhängigkeiten auf. Innerhalb der Services taucht der Container nicht mehr auf. Wer einen Container in einer Service-Klasse sieht, sollte das im Code-Review markieren.
Best Practices und Stolperfallen bei PHP Dependency Injection
Drei Regeln vereinfachen den Alltag mit PHP Dependency Injection. Erstens: Pflicht-Abhängigkeiten kommen in den Konstruktor, optionale Abhängigkeiten über Setter. Zweitens: Der Container wird ausschließlich im Bootstrap und in Factories verwendet, niemals in Services. Drittens: Type-Hints zeigen genau, was die Klasse braucht, also lieber Interface als konkrete Klasse, damit die Implementierung austauschbar bleibt.
Eine häufige Stolperfalle ist die Verwechslung mit Composer. Composer ist ein Paket-Manager und sorgt dafür, dass externe Bibliotheken installiert werden. PHP Dependency Injection dagegen organisiert die internen Beziehungen zwischen den eigenen Klassen. Beide arbeiten zusammen, sind aber komplett verschiedene Ebenen.
Ein weiteres Detail betrifft Auto-Wiring. Fortgeschrittene Container lösen Konstruktor-Parameter automatisch über Reflection auf. Das spart Konfiguration, kann aber bei mehreren Implementierungen eines Interfaces verwirrend werden. In dem Fall hilft eine explizite Definition im Container, die festlegt, welche Implementierung für welchen Service genutzt wird. Ebenfalls hilfreich sind Container-Module: jede große Anwendung sollte ihre Service-Definitionen in mehreren kleinen Konfig-Dateien organisieren, eine pro Modul oder Bounded-Context, sonst wird der Bootstrap zur God-Datei.
In Bezug auf den Lebenszyklus unterscheiden viele Container zwischen "Singleton-Scope" (eine Instanz pro Container-Lebenszyklus) und "Prototype-Scope" (jede get()-Anfrage liefert eine neue Instanz). Im Default-Fall ist Singleton-Scope sinnvoll und sicher, weil der gesamte Request in PHP ohnehin ein abgeschlossener Lebenszyklus ist. Prototype-Scope lohnt nur für veränderliche Objekte oder solche, die Zustand sammeln, der pro Anfrage frisch starten muss. Wer hier unsicher ist, bleibt beim Default und wechselt nur dann, wenn ein konkretes Problem auftaucht. So bleibt die PHP Dependency Injection vorhersagbar, und der Container entwickelt sich nicht in eine schwer zu wartende Magic-Box.
Fazit zu PHP Dependency Injection
PHP Dependency Injection ist eines der wichtigsten Patterns in moderner PHP-Entwicklung. Constructor Injection als Standard, Setter Injection für optionale Abhängigkeiten und ein schlanker DI-Container nach PSR-11 reichen für die meisten Projekte vollständig aus. Wer das Service-Locator-Antipattern vermeidet und konsequent Type-Hints auf Interfaces nutzt, gewinnt austauschbare Implementierungen, klare Abhängigkeiten und gut testbare Services. Damit löst PHP Dependency Injection genau die Probleme, die das Singleton-Pattern hinterlassen hat, und bildet die Grundlage für wartbare Architekturen in PHP-Projekten jeder Größe.