praktisch umgesetzt in PHP 8.4
Unveränderliche Objekte verändern grundlegend, wie zuverlässig sich PHP-Code verhält: Ein einmal konstruiertes Value Object kann später von keiner Stelle im Programm heimlich verändert werden, weil es schlicht keine Setter mehr gibt. Statt bestehende Instanzen zu mutieren, erzeugen with-Methoden bei jeder Änderung eine neue, vollständig initialisierte Kopie mit dem geänderten Wert. Dieser Beitrag zeigt an einem durchgehenden Money-Beispiel, wie readonly-Properties, with-Methoden und Named Constructors zusammenspielen, und warum Klonen mit anschließender Mutation bei readonly-Properties in PHP technisch gar nicht funktioniert.
Inhaltsverzeichnis
- 1. Was Immutabilität für ein Objekt bedeutet
- 2. readonly-Properties und Constructor Property Promotion
- 3. Das withX()-Pattern im Detail
- 4. Named Constructors als Begleit-Pattern
- 5. Gleichheit und Vergleichbarkeit unveränderlicher Value Objects
- 6. Validierung und Invarianten bei der Konstruktion
- 7. Praktische Vorteile jenseits vermiedener Mutation-Bugs
- 8. Wann Immutability das falsche Werkzeug ist
- 9. Mutable vs. Immutable im direkten Vergleich
- 10. Zusammenfassung
- 11. FAQ
1. Was Immutabilität für ein Objekt bedeutet
Ein Immutable Object, auf Deutsch ein unveränderliches Objekt, ist eine Instanz, deren Zustand nach der Konstruktion nicht mehr verändert werden kann. Es gibt keine setAmount()-Methode, keine öffentliche Property, die von außen zugewiesen wird, keinen Zustand, der sich zur Laufzeit verschiebt. Der Wert eines Objekts steht fest, sobald der Konstruktor durchgelaufen ist, und bleibt für die gesamte Lebensdauer der Instanz identisch. Das steht im direkten Gegensatz zum klassischen mutable Objekt mit öffentlichen Settern, bei dem jede Codezeile, die eine Referenz auf das Objekt hält, dessen Zustand jederzeit verändern kann, oft ohne dass der Rest des Programms davon erfährt.
Genau dieser geteilte Zustand ist die Wurzel eines der häufigsten und am schwersten zu findenden Fehlerklassen in objektorientiertem PHP-Code: der Aliasing-Bug. Zwei Variablen zeigen auf dieselbe Objektinstanz, eine Codezeile ändert das Objekt über die eine Variable, und die andere Variable sieht plötzlich einen veränderten Zustand, obwohl sie selbst nie angefasst wurde. Das folgende Beispiel zeigt diesen Effekt an einem klassischen mutable Money-Objekt, wie es in vielen gewachsenen Warenkorb-Implementierungen vorkommt.
<?php
declare(strict_types=1);
namespace Shop\Pricing;
// Mutable value object: any code holding a reference can change it
final class Money
{
private int $amountInCents;
private string $currency;
public function __construct(int $amountInCents, string $currency)
{
$this->amountInCents = $amountInCents;
$this->currency = $currency;
}
public function setAmountInCents(int $amountInCents): void
{
$this->amountInCents = $amountInCents;
}
public function getAmountInCents(): int
{
return $this->amountInCents;
}
}
// Aliasing bug: two variables reference the SAME instance
$subtotal = new Money(10000, 'EUR'); // 100.00 EUR
$cartTotal = $subtotal; // no copy, just another reference to the same object
// Somewhere else in the codebase, a discount gets applied...
$cartTotal->setAmountInCents(8000); // 80.00 EUR
// $subtotal changed too, even though nothing touched it directly
echo $subtotal->getAmountInCents(); // 8000, not 10000
Das Problem in diesem Beispiel liegt nicht in einem offensichtlichen Tippfehler, sondern in der Struktur selbst: Solange Money mutable ist, kann jede Stelle im Code, die eine Referenz besitzt, den Zustand für alle anderen Stellen gleichzeitig verändern. Ein Immutable Object eliminiert diese Fehlerklasse strukturell, nicht durch Disziplin oder Codereview, sondern dadurch, dass es schlicht keine Methode gibt, die den Zustand nachträglich verändern könnte.
2. readonly-Properties und Constructor Property Promotion
Seit PHP 8.1 gibt es mit readonly ein natives Sprachfeature für genau dieses Problem. Eine als readonly deklarierte Property kann nur ein einziges Mal initialisiert werden, und zwar ausschließlich aus dem Scope der deklarierenden Klasse heraus, typischerweise im Konstruktor. Jeder weitere Schreibzugriff, egal ob von außerhalb der Klasse oder ein zweiter Zuweisungsversuch innerhalb derselben Methode, führt zu einem Error. Kombiniert mit Constructor Property Promotion lässt sich ein unveränderliches Objekt in PHP 8.4 in wenigen Zeilen ohne Boilerplate deklarieren.
Der Konstruktor wird dadurch zur einzigen Stelle im gesamten Klassenkörper, an der der Zustand jemals gesetzt wird. Das ist kein zusätzlicher Stilentscheid, sondern eine vom Sprachlaufzeit erzwungene Eigenschaft: Es gibt keinen Setter, weil eine Setter-Methode auf eine readonly-Property nach der ersten Initialisierung ohnehin nur eine Exception werfen würde. Das folgende Beispiel zeigt die minimale immutable Variante von Money und den Fehler, der bei einem externen Schreibzugriff entsteht.
<?php
declare(strict_types=1);
namespace Shop\Pricing;
// Immutable value object: state is fixed once the constructor returns
final class Money
{
public function __construct(
public readonly int $amountInCents,
public readonly string $currency,
) {
}
}
$price = new Money(10000, 'EUR');
// Attempting to write from outside the declaring class scope...
$price->amountInCents = 8000;
// Error: Cannot modify readonly property Money::$amountInCents
Wichtig ist die genaue Formulierung der Regel: readonly verbietet nicht nur den Zugriff von außen, sondern jede zweite Zuweisung überhaupt, auch aus dem Konstruktor selbst heraus. Wer versehentlich versucht, $this->amountInCents in einer zweiten Methode der eigenen Klasse erneut zu setzen, erhält denselben Fehler. Diese Strenge ist beabsichtigt: Sie ist die Grundlage, auf der das gesamte with-Methoden-Pattern für Immutable Objects im nächsten Abschnitt aufbaut.
3. Das withX()-Pattern im Detail
An dieser Stelle entsteht ein häufiges Missverständnis: Wer eine geänderte Kopie eines unveränderlichen Objekts braucht, könnte auf die Idee kommen, das Objekt zu klonen und danach die geänderte Property auf der Kopie zu setzen. Das funktioniert bei readonly-Properties in PHP jedoch nicht. clone erzeugt zwar eine flache Kopie des Objekts, aber jede readonly-Property der Kopie gilt weiterhin als bereits initialisiert, genau wie beim Original. PHP kennt kein Sprachfeature für ein "Clone mit anschließend geänderter readonly-Property". Ein Schreibzugriff auf die geklonte Instanz außerhalb des deklarierenden Scopes wirft exakt denselben Fehler wie im vorigen Abschnitt.
Der korrekte Weg führt über eine withX()-Methode, die keine bestehende Instanz verändert, sondern eine vollständig neue Instanz über new self(...) konstruiert und zurückgibt. Alle unveränderten Werte werden dabei unverändert an den neuen Konstruktoraufruf durchgereicht, nur der eine geänderte Wert wird ersetzt. Das Ergebnis ist ein neues, eigenständiges Objekt, während die ursprüngliche Instanz unangetastet bleibt und weiterhin an jeder Stelle, die sie referenziert, ihren alten Wert zeigt.
<?php
declare(strict_types=1);
namespace Shop\Pricing;
final class Money
{
public function __construct(
public readonly int $amountInCents,
public readonly string $currency,
) {
}
// withX(): build and return a brand new instance, never mutate this one
public function withAmountInCents(int $amountInCents): self
{
return new self($amountInCents, $this->currency);
}
public function withCurrency(string $currency): self
{
return new self($this->amountInCents, $currency);
}
public function add(self $other): self
{
if ($other->currency !== $this->currency) {
throw new \InvalidArgumentException('Cannot add Money in different currencies');
}
return new self($this->amountInCents + $other->amountInCents, $this->currency);
}
}
$price = new Money(10000, 'EUR');
$discounted = $price->withAmountInCents(8000); // new instance, $price stays untouched
$inUsd = $discounted->withCurrency('USD'); // yet another new instance
echo $price->amountInCents; // 10000, still the original value
echo $discounted->amountInCents; // 8000
Diese Verkettung, $price->withAmountInCents(8000)->withCurrency('USD'), erzeugt zwei Zwischeninstanzen, von denen jede für sich vollständig unveränderlich und vollständig gültig ist. Kein Schritt in dieser Kette verändert ein bereits existierendes Objekt, und genau das macht das with-Methoden-Pattern zur richtigen Antwort auf die Frage, wie man Immutable Objects in PHP praktisch verändert, ohne die Sprachgarantie von readonly zu unterlaufen.
4. Named Constructors als Begleit-Pattern
Ein einzelner, allgemeiner Konstruktor stößt schnell an Grenzen, sobald ein Value Object aus unterschiedlichen Quellen entstehen soll: aus einem Float-Betrag, aus einer API-Antwort in Minor Units, als expliziter Nullwert. Statt einen einzigen Konstruktor mit optionalen Parametern und internem Verzweigungscode zu überladen, hält man den eigentlichen Konstruktor minimal, oft sogar private, und stellt stattdessen benannte statische Fabrikmethoden bereit. Jede dieser Methoden beschreibt durch ihren Namen, aus welchem Kontext eine Instanz entsteht, und kann eigene Validierungs- und Umrechnungslogik kapseln.
Weil withX()-Methoden und Named Constructors im selben Klassenkörper stehen, dürfen sie einen als private deklarierten Konstruktor trotzdem über new self(...) aufrufen. PHP prüft die Sichtbarkeit relativ zur deklarierenden Klasse, nicht relativ zur aufrufenden Zeile im Code, solange der Aufruf innerhalb derselben Klasse erfolgt.
<?php
declare(strict_types=1);
namespace Shop\Pricing;
final class Money
{
private function __construct(
public readonly int $amountInCents,
public readonly string $currency,
) {
}
public static function fromFloat(float $amount, string $currency): self
{
return new self((int) round($amount * 100), $currency);
}
public static function fromMinorUnits(int $amountInCents, string $currency): self
{
return new self($amountInCents, $currency);
}
public static function zero(string $currency): self
{
return new self(0, $currency);
}
// withX() lives in the same class, so it may still call the private constructor
public function withAmountInCents(int $amountInCents): self
{
return new self($amountInCents, $this->currency);
}
}
$price = Money::fromFloat(99.90, 'EUR');
$empty = Money::zero('EUR');
$fromApi = Money::fromMinorUnits(4599, 'USD');
Named Constructors machen den Einstiegspunkt für ein unveränderliches Objekt lesbar, statt eine einzige überladene Signatur zu raten. Money::zero('EUR') liest sich an jeder Aufrufstelle selbsterklärend, während ein generischer Konstruktor mit einem stillschweigenden Nullwert diese Absicht verschleiern würde.
5. Gleichheit und Vergleichbarkeit unveränderlicher Value Objects
Bei Value Objects stellt sich fast immer die Frage, ob zwei Instanzen als gleich gelten sollen. PHPs ===-Operator prüft Objektidentität, also ob beide Variablen exakt dieselbe Instanz im Speicher referenzieren. Zwei separat konstruierte Money-Instanzen mit identischem Betrag und identischer Währung sind bei === trotzdem ungleich, weil es zwei unterschiedliche Objekte sind. Das ist fachlich fast nie das, was man will: 100 Euro sind 100 Euro, unabhängig davon, welche konkrete Instanz sie repräsentiert. Der lose Vergleichsoperator == vergleicht bei Objekten derselben Klasse Eigenschaft für Eigenschaft und liefert für zwei wertgleiche Money-Instanzen bereits true.
Trotzdem ist es meist die robustere Wahl, eine explizite equals()-Methode zu implementieren, statt sich allein auf == zu verlassen. Eine explizite Methode macht die Vergleichsabsicht im Code sichtbar, lässt sich gezielt auf die fachlich relevanten Properties beschränken und bleibt stabil, selbst wenn später zusätzliche, für die Gleichheit irrelevante Properties hinzukommen. Immutabilität vereinfacht diese Überlegung erheblich: Weil ein Immutable Object seinen Zustand nach der Konstruktion nicht mehr ändert, kann sich das Ergebnis eines Vergleichs auch nicht nachträglich verschieben. Eine einmal als gleich befundene Instanz bleibt gleich, solange sie existiert, was bei mutable Objekten mit geteilten Referenzen keineswegs garantiert ist.
6. Validierung und Invarianten bei der Konstruktion
Ein Immutable Object kann nur dann zuverlässig unveränderlich sein, wenn es niemals in einem ungültigen Zustand existiert. Deshalb gehört Validierung konsequent in den Konstruktor beziehungsweise in die Named Constructors, die ihn aufrufen. Wird dort bei einem ungültigen Wert eine Exception geworfen, kann die Instanz das Konstrukt gar nicht erst verlassen, ohne alle Invarianten zu erfüllen. Es gibt anschließend keine Setter-Stelle mehr, an der ein ungültiger Wert später unbemerkt hineinrutschen könnte, weil es schlicht keine Setter gibt.
<?php
declare(strict_types=1);
namespace Shop\Pricing;
final class Money
{
public function __construct(
public readonly int $amountInCents,
public readonly string $currency,
) {
if ($amountInCents < 0) {
throw new \InvalidArgumentException('Amount must not be negative');
}
if (!preg_match('/^[A-Z]{3}$/', $currency)) {
throw new \InvalidArgumentException(sprintf('Invalid currency code: %s', $currency));
}
}
public function withAmountInCents(int $amountInCents): self
{
// Validation runs again automatically, because it lives in the constructor
return new self($amountInCents, $this->currency);
}
}
// Throws immediately, the invalid instance never exists
$broken = new Money(-500, 'EUR');
Bei einem mutable Objekt mit Settern muss dieselbe Validierungslogik dagegen in jedem einzelnen Setter wiederholt werden, weil jeder Setter ein potenzieller Eintrittspunkt für einen ungültigen Wert ist. Vergisst man die Prüfung in nur einem von mehreren Settern, kann das Objekt über genau diesen vergessenen Pfad in einen ungültigen Zustand geraten. Bei einem unveränderlichen Objekt existiert dieses Risiko nicht, weil jede withX()-Methode intern denselben Konstruktor aufruft und die Validierung damit automatisch mitläuft, ohne dass man sie an mehreren Stellen pflegen muss.
7. Praktische Vorteile jenseits vermiedener Mutation-Bugs
Der offensichtlichste Vorteil von Immutable Objects ist die Vermeidung von Aliasing-Bugs, aber der praktische Nutzen reicht deutlich weiter. Ein unveränderliches Objekt kann bedenkenlos über Funktionsgrenzen hinweg weitergereicht werden, ohne dass eine aufrufende Funktion eine defensive Kopie anfertigen muss, um sich vor ungewollten Seiteneffekten zu schützen. Da keine Methode existiert, die den Zustand verändern könnte, ist die reine Referenzübergabe bereits so sicher wie eine vollständige Kopie, nur ohne deren Kopieraufwand.
Dieselbe Eigenschaft vereinfacht das Denken über nebenläufigen oder asynchronen Code erheblich. Wenn mehrere Fibers, Coroutinen oder parallele Anfragen dieselbe Instanz referenzieren, gibt es keinen Wettlauf um den Schreibzugriff, weil es keinen Schreibzugriff gibt. Zusätzlich eignen sich Immutable Objects hervorragend für Caching und Memoization: Da der fachliche Wert einer Instanz nie von ihrem Anfangszustand abweicht, kann ein einmal berechnetes Ergebnis, das von diesem Objekt abhängt, gefahrlos zwischengespeichert werden, ohne dass eine spätere Mutation das Cache-Ergebnis unbemerkt ungültig macht.
Auch beim Einsatz in Arrays und Collections zeigt sich der Vorteil: Ein Value Object, das einmal in ein Array eingefügt wurde, verhält sich später garantiert immer noch genauso wie im Moment des Einfügens. Bei mutable Objekten kann sich ein bereits gespeichertes Element über eine geteilte Referenz nachträglich verändern, ohne dass die Struktur, die es enthält, davon erfährt, was zu inkonsistenten Zuständen führt, die sich nur schwer nachvollziehen lassen.
8. Wann Immutability das falsche Werkzeug ist
Immutabilität ist kein Universalprinzip, das man auf jede Klasse anwenden sollte. Bei sehr großen Objekten oder in Hot-Loops, die tausende Änderungen pro Sekunde durchführen, kostet jede withX()-Aufruf eine neue Objektallokation samt Kopie aller unveränderten Werte. Für kleine Value Objects wie Money oder Address ist dieser Overhead in der Praxis vernachlässigbar, bei großen, tief verschachtelten Strukturen mit hoher Änderungsfrequenz kann er jedoch spürbar werden.
Genauso wichtig ist die Unterscheidung zwischen Value Objects und Entitäten mit echter Identität. Ein Order-Objekt, das über eine ID verfolgt wird und dessen Status sich über den Lebenszyklus einer Bestellung tatsächlich verändert, von pending über paid bis shipped, ist meist besser als mutable Objekt modelliert, das seinen Zustand über klar benannte Methoden wie markAsPaid() kontrolliert verändert. Der entscheidende Unterschied: Ein Value Object wird über seinen Wert identifiziert, eine Entität über ihre ID, und nur bei Ersterem ist die Frage "was, wenn sich der Wert ändert" durch "dann ist es ein anderes Objekt" sauber beantwortbar.
In der Praxis führt das zu einem gemischten Ansatz: Value Objects wie Geldbeträge, Adressen oder Zeiträume werden konsequent unveränderlich modelliert, während Entitäten mit Identität und echtem Lebenszyklus, wie Bestellungen, Benutzerkonten oder Warenkörbe, bewusst mutable bleiben. Beide Modellierungsarten schließen sich innerhalb desselben Projekts nicht aus, sie lösen unterschiedliche Probleme und werden bewusst nebeneinander eingesetzt.
9. Mutable vs. Immutable im direkten Vergleich
Die Entscheidung zwischen einem klassischen mutable Objekt mit Settern und einem Immutable Object mit with-Methoden lässt sich anhand einiger wiederkehrender Kriterien systematisch treffen. Die folgende Tabelle stellt beide Ansätze entlang der Punkte gegenüber, die in der Praxis am häufigsten zu Fehlern oder Diskussionen führen.
| Kriterium | Mutable Objekt mit Settern | Immutable Objekt mit with-Methoden |
|---|---|---|
| Aliasing-Risiko | Hoch: geteilte Referenzen können unbemerkt verändert werden | Keins: jede Änderung erzeugt eine neue Instanz |
| Ort der Validierung | Muss in jedem einzelnen Setter wiederholt werden | Einmalig im Konstruktor beziehungsweise Named Constructor |
| Thread-/Async-Sicherheit | Riskant bei gleichzeitigem Zugriff mehrerer Prozesse | Sicher, da der Zustand nach der Konstruktion fix ist |
| Gleichheitssemantik | State kann sich zwischen zwei Vergleichen verschieben | equals() bleibt über die Objektlebensdauer stabil |
| Typischer Einsatzzweck | Entitäten mit Identität und Lebenszyklus (Order, User) | Value Objects (Money, Address, DateRange) |
Die Tabelle macht deutlich, dass es sich nicht um eine reine Geschmacksfrage handelt. Aliasing-Risiko, Validierungsaufwand und Nebenläufigkeitssicherheit sind bei mutable Objekten strukturell schlechter, unabhängig davon, wie sorgfältig der einzelne Entwickler programmiert. Umgekehrt ist die letzte Zeile der Grund, warum man nicht jede Klasse unveränderlich modelliert: Objekte mit echter Identität und Lebenszyklus profitieren von kontrollierter Mutation über klar benannte Methoden mehr als von einer Kette immer neuer Instanzen.
10. Zusammenfassung
Immutable Objects lösen das Aliasing-Problem nicht durch Konvention, sondern durch Sprachgarantien: readonly-Properties lassen sich nur einmal aus dem deklarierenden Scope heraus setzen, und jeder weitere Schreibversuch, auch nach einem clone, wird von PHP mit einem Error verhindert. Der korrekte Weg, ein unveränderliches Objekt mit einem geänderten Wert zu erzeugen, führt deshalb nicht über Klonen und Mutieren, sondern über withX()-Methoden, die eine vollständig neue Instanz über new self(...) konstruieren und alle unveränderten Werte durchreichen.
Named Constructors ergänzen dieses Pattern um lesbare, validierte Einstiegspunkte, während eine explizite equals()-Methode für fachlich korrekte Wertvergleiche sorgt. Validierung im Konstruktor stellt sicher, dass ein Immutable Object niemals in ungültigem Zustand existiert, was bei mutable Objekten mit wiederholter Setter-Validierung deutlich fehleranfälliger ist. Nicht jede Klasse profitiert davon: Entitäten mit echter Identität und Lebenszyklus bleiben bewusst mutable, während Value Objects wie Geldbeträge oder Adressen konsequent unveränderlich modelliert werden.
Immutable Objects in PHP, Das Wichtigste auf einen Blick
readonly-Properties
Nur einmal aus dem deklarierenden Scope setzbar. Jeder weitere Schreibversuch, auch nach clone, wirft einen Error.
withX() statt clone
Immer eine neue Instanz über new self(...) konstruieren, nie eine geklonte Kopie nachträglich mutieren.
Named Constructors
fromFloat(), zero(), fromMinorUnits() als lesbare, validierte Einstiegspunkte statt eines überladenen Konstruktors.
Wann nicht immutable
Entitäten mit Identität und Lebenszyklus (Order, User) bleiben besser mutable statt ständig neue Instanzen zu erzeugen.
11. FAQ: Immutable Objects in PHP
1Was ist ein Immutable Object in PHP?
2readonly-Property nach clone überschreiben?
3with-Methoden vs. Setter?
4Warum equals() statt ===?
5Ungültigen Zustand verhindern?
6readonly wie const?
7Was kostet Immutability an Performance?
8Wann lieber mutable?
9with-Methoden mit Named Constructors kombinieren?
10Array in readonly-Property unveränderlich?
Mironsoft
PHP-Entwicklung, Value-Object-Design und Codereview
Immutable Objects sauber in eurem PHP-Projekt einführen?
Wir helfen Teams, Value Objects mit readonly-Properties, with-Methoden und Named Constructors konsequent zu modellieren, und zeigen, wo mutable Entitäten die passendere Wahl bleiben.
Code-Review
Bestehende Value Objects auf Aliasing-Risiken und fehlende Invarianten prüfen
Refactoring
Setter-basierte Klassen schrittweise auf readonly-Properties und with-Methoden umstellen
Architektur-Workshop
Value Objects und Entitäten sauber trennen, direkt am bestehenden Domänenmodell