eine Übersicht unabhängig von Magento und Symfony
Entwurfsmuster werden oft an ein bestimmtes Framework gebunden erklärt, dabei sind es benannte, wiederverwendbare Lösungsformen für Entwurfsprobleme, die in jeder objektorientierten PHP-Codebasis auftauchen, unabhängig davon, ob ein Framework überhaupt im Spiel ist. Dieser Beitrag stellt die wichtigsten klassischen Entwurfsmuster, Factory Method, Singleton, Decorator, Strategy, Observer und Repository, ausschließlich anhand von reinem PHP 8.4 vor, gegliedert nach den drei klassischen GoF-Kategorien Creational, Structural und Behavioral. Kein Magento, kein Symfony, keine ORM-Bindung, nur die Mechanik der Muster selbst und eine ehrliche Einschätzung, wann sich der zusätzliche Code tatsächlich lohnt.
Inhaltsverzeichnis
- 1. Was ein Entwurfsmuster wirklich ist
- 2. Creational Patterns Teil 1: Factory Method
- 3. Creational Patterns Teil 2: Singleton und seine Grenzen
- 4. Structural Patterns: Decorator
- 5. Behavioral Patterns Teil 1: Strategy
- 6. Behavioral Patterns Teil 2: Observer
- 7. Repository als Architektur-Grenze
- 8. Muster kombinieren, ohne zu überengineeren
- 9. Entwurfsmuster im direkten Vergleich
- 10. Zusammenfassung
- 11. FAQ
1. Was ein Entwurfsmuster wirklich ist
Ein Entwurfsmuster ist eine benannte, wiederverwendbare Lösungsform für ein wiederkehrendes Entwurfsproblem, keine Bibliothek, kein Framework-Feature und kein fertiges Stück Code zum Kopieren. Es beschreibt eine Struktur aus Klassen und ihren Beziehungen zueinander, die ein bestimmtes Entwurfsproblem lösen, ohne an eine konkrete Implementierung, eine bestimmte Programmiersprache oder gar ein bestimmtes Framework gebunden zu sein. Wenn zwei Entwickler von einer "Factory" oder einer "Strategy" sprechen, verständigen sie sich über eine Struktur, nicht über eine bestimmte Codezeile.
Die Design-Patterns-Bewegung wurde durch das Buch der sogenannten "Gang of Four" geprägt, das die Muster in drei Kategorien einteilt: Creational Patterns befassen sich mit der Erzeugung von Objekten, Structural Patterns mit der Zusammensetzung von Klassen und Objekten zu größeren Strukturen, Behavioral Patterns mit der Verteilung von Verantwortung und Kommunikation zwischen Objekten. Diese drei Kategorien dienen als Landkarte für den Rest dieses Beitrags: Jedes vorgestellte Muster wird genau einer dieser Kategorien zugeordnet, damit klar wird, welches Grundproblem es eigentlich löst.
Wichtig ist von Anfang an die Abgrenzung: Ein Entwurfsmuster ist unabhängig von Magento, Symfony oder jedem anderen Framework. Framework-spezifische Implementierungen, etwa wie Magento seinen eigenen ObjectManager oder seine eigenen Factory-Klassen strukturiert, sind lediglich eine konkrete Anwendung des allgemeinen Prinzips auf eine bestimmte Codebasis. Dieser Beitrag bleibt bewusst auf der Ebene des allgemeinen Prinzips, mit reinem PHP-Code, der in jedem Projekt unabhängig vom eingesetzten Framework funktioniert.
2. Creational Patterns Teil 1: Factory Method
Das Problem, das Factory Method löst, entsteht fast immer auf dieselbe Art: Ein new ConcreteClass()-Aufruf ist über eine Codebasis verstreut, und jede dieser Stellen muss geändert werden, sobald sich entscheidet, welche konkrete Klasse tatsächlich instanziiert werden soll. Statt diese Entscheidung an jeder Aufrufstelle zu wiederholen, kapselt eine Factory-Methode die Entscheidungslogik an genau einer Stelle und liefert eine Instanz zurück, die über ein gemeinsames Interface typisiert ist.
Der Aufrufer kennt dabei nur das Interface, nicht die konkrete Klasse. Das entkoppelt den Code, der ein Objekt benutzt, vollständig vom Code, der entscheidet, welche konkrete Implementierung dieses Objekt tatsächlich sein soll.
<?php
declare(strict_types=1);
namespace Notifications;
interface NotifierInterface
{
public function send(string $message): void;
}
final class EmailNotifier implements NotifierInterface
{
public function send(string $message): void
{
// ... send an email
}
}
final class SmsNotifier implements NotifierInterface
{
public function send(string $message): void
{
// ... send an SMS
}
}
// Factory Method: one place decides which concrete class is returned
final class NotifierFactory
{
public static function create(string $channel): NotifierInterface
{
return match ($channel) {
'email' => new EmailNotifier(),
'sms' => new SmsNotifier(),
default => throw new \InvalidArgumentException("Unknown channel: $channel"),
};
}
}
$notifier = NotifierFactory::create('email');
$notifier->send('Order confirmed');
Neue Kanäle lassen sich hinzufügen, ohne den aufrufenden Code an irgendeiner Stelle anzufassen, solange nur die Factory-Methode selbst um einen neuen match-Zweig erweitert wird. Das ist der eigentliche Nutzen von Factory Method: Änderungen an der Erzeugungslogik bleiben lokal, statt sich über die gesamte Codebasis zu verteilen.
3. Creational Patterns Teil 2: Singleton und seine Grenzen
Singleton verspricht, dass genau eine Instanz einer Klasse während der gesamten Laufzeit eines Programms existiert, zugänglich über einen globalen statischen Zugriffspunkt. In der Praxis hat sich dieses Muster jedoch zu einem der am häufigsten kritisierten Entwurfsmuster überhaupt entwickelt, weil sein tatsächlicher Preis den versprochenen Nutzen meist übersteigt.
Der Preis liegt in globalem, mutierbarem Zustand: Jede Klasse, die intern auf ein Singleton zugreift, hat eine versteckte Abhängigkeit, die aus ihrer öffentlichen Signatur nicht ersichtlich ist. Das erschwert Tests erheblich, weil ein Singleton zwischen Testfällen zurückgesetzt werden muss, um Seiteneffekte zu vermeiden, und macht den tatsächlichen Abhängigkeitsgraphen einer Anwendung schwerer nachvollziehbar. Die moderne Alternative ist Dependency Injection: Eine Instanz wird einmal zentral erzeugt und explizit über den Konstruktor an jede Stelle weitergereicht, die sie benötigt, statt sich über einen globalen Zugriffspunkt selbst zu beschaffen. Die Instanz bleibt dabei durchaus einzigartig, nur die Art, wie Code an sie herankommt, ändert sich von implizit-global zu explizit-injiziert, was Testbarkeit und Nachvollziehbarkeit deutlich verbessert.
4. Structural Patterns: Decorator
Decorator löst das Problem, ein Objekt um zusätzliches Verhalten zu erweitern, ohne dessen Klasse selbst zu verändern und ohne für jede denkbare Kombination von Zusatzverhalten eine eigene Unterklasse zu schreiben. Ein Decorator implementiert dasselbe Interface wie das Objekt, das er umschließt, hält intern eine Referenz auf dieses Objekt und ruft dessen Methode auf, ergänzt um eigenes Verhalten davor oder danach.
Der entscheidende Vorteil gegenüber Vererbung: Mehrere Decorators lassen sich beliebig verschachteln, jeder fügt genau eine zusätzliche Fähigkeit hinzu, ohne dass eine kombinatorische Explosion von Unterklassen für jede mögliche Kombination entstehen müsste.
<?php
declare(strict_types=1);
namespace Notifications;
interface NotifierInterface
{
public function send(string $message): void;
}
final class BaseNotifier implements NotifierInterface
{
public function send(string $message): void
{
echo "Sending: {$message}\n";
}
}
// Decorator: wraps any NotifierInterface, adds behavior without modifying it
final class LoggingNotifierDecorator implements NotifierInterface
{
public function __construct(
private readonly NotifierInterface $inner,
) {
}
public function send(string $message): void
{
error_log(sprintf('Sending notification: %s', $message));
$this->inner->send($message);
}
}
final class RateLimitedNotifierDecorator implements NotifierInterface
{
private int $sentCount = 0;
public function __construct(
private readonly NotifierInterface $inner,
private readonly int $maxPerRequest = 5,
) {
}
public function send(string $message): void
{
if ($this->sentCount >= $this->maxPerRequest) {
throw new \RuntimeException('Rate limit exceeded for this request');
}
$this->sentCount++;
$this->inner->send($message);
}
}
// Stacking decorators: each one adds exactly one capability
$notifier = new RateLimitedNotifierDecorator(
new LoggingNotifierDecorator(new BaseNotifier())
);
$notifier->send('Order confirmed');
Der aufrufende Code kennt weiterhin nur NotifierInterface und muss nichts über die konkrete Verschachtelung wissen. Jeder Decorator lässt sich unabhängig testen, und neue Zusatzfähigkeiten kommen als neuer Decorator hinzu, ohne bestehenden Code zu verändern.
5. Behavioral Patterns Teil 1: Strategy
Strategy kapselt austauschbare Algorithmen hinter einem gemeinsamen Interface und wählt zur Laufzeit aus, welcher konkrete Algorithmus tatsächlich verwendet wird. Statt eine große if/match-Kaskade innerhalb einer Klasse zu pflegen, die für jede Variante eine eigene Verzweigung enthält, wird jede Variante als eigene Klasse implementiert und der Kontext-Klasse von außen übergeben.
Neue Varianten lassen sich hinzufügen, ohne die Kontext-Klasse selbst zu verändern, was dem Open/Closed-Prinzip entspricht: offen für Erweiterung, geschlossen für Modifikation.
<?php
declare(strict_types=1);
namespace Pricing;
interface PricingStrategyInterface
{
public function calculate(int $baseAmountInCents): int;
}
final class RegularPricing implements PricingStrategyInterface
{
public function calculate(int $baseAmountInCents): int
{
return $baseAmountInCents;
}
}
final class PercentageDiscountPricing implements PricingStrategyInterface
{
public function __construct(private readonly int $percentOff)
{
}
public function calculate(int $baseAmountInCents): int
{
return (int) round($baseAmountInCents * (100 - $this->percentOff) / 100);
}
}
// Context: holds a strategy, delegates the actual calculation
final class PriceCalculator
{
public function __construct(
private readonly PricingStrategyInterface $strategy,
) {
}
public function priceFor(int $baseAmountInCents): int
{
return $this->strategy->calculate($baseAmountInCents);
}
}
$calculator = new PriceCalculator(new PercentageDiscountPricing(20));
echo $calculator->priceFor(10000); // 8000
6. Behavioral Patterns Teil 2: Observer
Observer entkoppelt eine Ereignisquelle von den Codeteilen, die auf ein Ereignis reagieren müssen, ohne dass die Quelle irgendetwas über die konkreten Beobachter wissen muss. Ein Subject hält eine Liste von Observern und benachrichtigt bei einem Ereignis alle registrierten Observer über ein gemeinsames Interface, ohne deren konkrete Klasse zu kennen.
Dieses Muster lässt sich vollständig mit einem einfachen, selbst definierten Interface umsetzen, ganz ohne die eingebauten SPL-Klassen SplSubject und SplObserver, die in der Praxis selten flexibel genug für reale Anforderungen sind.
<?php
declare(strict_types=1);
namespace Orders;
interface OrderObserverInterface
{
public function onOrderPlaced(string $orderId): void;
}
final class OrderPlacementLogger implements OrderObserverInterface
{
public function onOrderPlaced(string $orderId): void
{
error_log("Order placed: {$orderId}");
}
}
final class OrderConfirmationMailer implements OrderObserverInterface
{
public function onOrderPlaced(string $orderId): void
{
// ... send confirmation email
}
}
// Subject: holds observers, notifies them without knowing their concrete class
final class OrderSubject
{
/** @var OrderObserverInterface[] */
private array $observers = [];
public function subscribe(OrderObserverInterface $observer): void
{
$this->observers[] = $observer;
}
public function placeOrder(string $orderId): void
{
// ... actual order placement logic
foreach ($this->observers as $observer) {
$observer->onOrderPlaced($orderId);
}
}
}
$subject = new OrderSubject();
$subject->subscribe(new OrderPlacementLogger());
$subject->subscribe(new OrderConfirmationMailer());
$subject->placeOrder('ORD-1001');
7. Repository als Architektur-Grenze
Repository unterscheidet sich von den vorherigen Mustern dadurch, dass es weniger eine einzelne Klassenbeziehung als vielmehr eine Architektur-Grenze beschreibt. Der Datenzugriff wird hinter einem Interface abstrahiert, sodass die eigentliche Geschäftslogik nicht davon abhängt, ob Daten aus einer Datenbank, einer Datei oder einem In-Memory-Speicher stammen. Business-Code programmiert ausschließlich gegen das Interface und weiß nichts über den konkreten Speichermechanismus dahinter.
Das folgende Beispiel zeigt bewusst eine In-Memory-Implementierung, ausdrücklich ohne jede Bindung an ein ORM oder ein Framework, um die reine Struktur des Musters sichtbar zu machen.
<?php
declare(strict_types=1);
namespace Users;
final class User
{
public function __construct(
public readonly int $id,
public readonly string $email,
) {
}
}
interface UserRepositoryInterface
{
public function find(int $id): ?User;
public function save(User $user): void;
}
// Concrete implementation, plain PHP, no ORM, no framework
final class InMemoryUserRepository implements UserRepositoryInterface
{
/** @var array<int, User> */
private array $users = [];
public function find(int $id): ?User
{
return $this->users[$id] ?? null;
}
public function save(User $user): void
{
$this->users[$user->id] = $user;
}
}
// Business logic depends only on the interface
final class RegisterUser
{
public function __construct(
private readonly UserRepositoryInterface $repository,
) {
}
public function handle(int $id, string $email): void
{
$this->repository->save(new User($id, $email));
}
}
Ein anderer Speichermechanismus, etwa eine echte Datenbankanbindung, lässt sich später als weitere Implementierung von UserRepositoryInterface ergänzen, ohne dass RegisterUser eine einzige Zeile ändern müsste. Genau diese Austauschbarkeit macht Repository zu einem der am häufigsten eingesetzten Entwurfsmuster in wachsenden Codebasen.
8. Muster kombinieren, ohne zu überengineeren
Entwurfsmuster lösen Probleme, die zuerst tatsächlich existieren müssen, bevor der zusätzliche Code, den ein Muster mitbringt, sich lohnt. Wer eine Factory für eine einzige konkrete Klasse baut, die niemals eine zweite Implementierung bekommen wird, fügt Indirektion hinzu, ohne dass ihr ein realer Nutzen gegenübersteht. Dasselbe gilt für Strategy mit genau einer Strategie oder Observer mit genau einem Observer, der niemals einen zweiten bekommen wird.
Ein brauchbares Warnsignal für Überengineering: Wenn eine Abstraktion existiert, aber seit ihrer Einführung nie eine zweite konkrete Implementierung dazugekommen ist, und auch keine konkret geplant ist, hat die Abstraktion wahrscheinlich mehr Komplexität hinzugefügt, als sie eingespart hat. Ein einfacher, direkter Funktionsaufruf oder eine einzelne Klasse ohne Interface ist in diesem Fall oft die klarere Lösung, und ein Entwurfsmuster lässt sich später immer noch einführen, sobald der tatsächliche Bedarf an Austauschbarkeit entsteht.
9. Entwurfsmuster im direkten Vergleich
Die folgende Tabelle fasst die in diesem Beitrag behandelten Entwurfsmuster entlang von Kategorie, gelöstem Problem und einem typischen Warnsignal für Überengineering zusammen.
| Muster | Kategorie | Gelöstes Problem | Warnsignal für Überengineering |
|---|---|---|---|
| Factory Method | Creational | Erzeugungslogik zentralisieren statt verstreuen | Nur eine konkrete Klasse existiert jemals |
| Singleton | Creational | Genau eine Instanz global verfügbar machen | Fast immer, wegen versteckter globaler Abhängigkeiten |
| Decorator | Structural | Verhalten ergänzen ohne Vererbungsexplosion | Nur eine einzige Zusatzfähigkeit wird je gebraucht |
| Strategy | Behavioral | Austauschbare Algorithmen zur Laufzeit wählen | Nur eine Variante existiert und wird es bleiben |
| Repository | Structural / Architektur | Datenzugriff von Geschäftslogik entkoppeln | Nie mehr als eine Speicherimplementierung geplant |
Die Tabelle macht sichtbar, dass jedes dieser Entwurfsmuster ein spezifisches Problem löst, und dass jedes auch ein spezifisches Warnsignal hat, an dem sich Überengineering erkennen lässt. Wer ein Muster einsetzt, ohne dass das zugehörige Problem in der eigenen Codebasis tatsächlich vorliegt, tauscht Einfachheit gegen Indirektion, ohne dafür einen Gegenwert zu erhalten.
10. Zusammenfassung
Entwurfsmuster sind benannte Lösungsformen für wiederkehrende Entwurfsprobleme, unabhängig von Magento, Symfony oder jedem anderen Framework. Factory Method zentralisiert Erzeugungslogik, Decorator ergänzt Verhalten ohne Vererbungsexplosion, Strategy macht Algorithmen zur Laufzeit austauschbar, Observer entkoppelt Ereignisquellen von ihren Reaktionen, Repository trennt Geschäftslogik von der konkreten Speicherimplementierung. Singleton dagegen gehört in den meisten modernen Codebasen eher zu den Mustern, die man bewusst vermeidet, weil sein tatsächlicher Preis, versteckte globale Abhängigkeiten und erschwerte Tests, den Nutzen meist übersteigt.
Der wichtigste Leitsatz bleibt: Ein Muster lohnt sich erst, wenn das Problem, das es löst, tatsächlich in der eigenen Codebasis existiert. Wo keine zweite Implementierung, keine austauschbare Strategie und kein realer Bedarf an Entkopplung besteht, fügt ein Entwurfsmuster nur zusätzliche Indirektion hinzu, ohne einen Gegenwert zu liefern.
Entwurfsmuster in PHP, Das Wichtigste auf einen Blick
Drei GoF-Kategorien
Creational (Erzeugung), Structural (Zusammensetzung), Behavioral (Verantwortung und Kommunikation).
Framework-unabhängig
Muster sind allgemeine Prinzipien. Magento- oder Symfony-Implementierungen sind nur konkrete Anwendungen davon.
Singleton mit Vorsicht
Globaler Zustand und versteckte Abhängigkeiten. Dependency Injection ist meist die robustere Alternative.
Warnsignal Überengineering
Eine Abstraktion ohne zweite Implementierung und ohne konkreten Bedarf kostet mehr, als sie einspart.
11. FAQ: Entwurfsmuster in PHP
1Was ist ein Entwurfsmuster in PHP?
2Drei Kategorien von Entwurfsmustern?
3Warum gilt Singleton als Anti-Pattern?
4Factory Method vs. Strategy?
5Wie funktioniert Decorator ohne Vererbung?
6SplObserver für Observer nötig?
7Ist Repository an ein ORM gebunden?
8Wann lohnt sich ein Muster nicht?
9An Magento oder Symfony gebunden?
10Überengineering erkennen?
Mironsoft
PHP-Entwicklung, Architektur-Review und Entwurfsmuster-Beratung
Entwurfsmuster gezielt in eurem PHP-Projekt einsetzen?
Wir helfen Teams, Factory, Strategy, Decorator, Observer und Repository dort einzusetzen, wo sie tatsächlich Nutzen bringen, und Überengineering gezielt zu vermeiden.
Architektur-Review
Bestehenden Code auf Überengineering und fehlende Entkopplung prüfen
Refactoring
Globale Zustände und Singletons durch Dependency Injection ersetzen
Entwurfs-Workshop
Passende Entwurfsmuster direkt am eigenen Domänenmodell erarbeiten