PHP 8.4 Named Arguments und Property Hooks: Fortgeschrittene Patterns jenseits der Grundlagen
Die meisten Teams, die PHP 8.4 eingefuehrt haben, nutzen Property Hooks dort, wo der Nutzen offensichtlich ist: ein Getter-Setter-Paar zu einer Deklaration zusammenfassen, einen Anzeigewert berechnen, eine negative Zahl beim Schreiben ablehnen. Dieser Teil ist gut dokumentiert, und unser frueherer Beitrag zu Property Hooks in Symfony behandelt ihn.
Die schwierigeren Fragen kommen spaeter, meist dann, wenn Doctrine Property Hooks zum ersten Mal auf eine Tabelle mit fuenf Jahren Historie treffen. Die Hydration laeuft nicht durch deinen Konstruktor. Eine Validierung, die du letzte Woche ergaenzt hast, kann ploetzlich fuer eine Zeile gelten, die 2021 geschrieben wurde. Und dasselbe Feature, das ein DTO angenehm lesbar macht, kann eine Entity auf eine Weise verhalten lassen, die sich aus einem Stacktrace nur schwer nachvollziehen laesst.
Wie sich Doctrine Property Hooks bei der Hydration verhalten
Doctrine hat deine Setter noch nie benutzt. Es hydriert Entities per Reflection und schreibt direkt in die Properties, damit das Laden einer Zeile keine Domaenenlogik ausloest. Mit einfachen Properties ist das unkompliziert. Mit gehookten Properties nicht, denn PHP 8.4 hat veraendert, was ein Reflection-Write bedeutet.
Nimm eine Entity mit einem Guard beim Schreiben:
final class Invoice
{
public int $amountInCents {
set(int $value) {
if ($value < 0) {
throw new \InvalidArgumentException('Amount cannot be negative.');
}
$this->amountInCents = $value;
}
}
}
In PHP 8.4 fuehrt ReflectionProperty::setValue() den Set-Hook aus. Fuer die meisten Aufrufer ist das der richtige Default, und fuer ein ORM ist es genau falsch. Um den Backing-Wert ohne Hook zu schreiben, hat PHP 8.4 ein Raw-Paar ergaenzt:
$property = new \ReflectionProperty(Invoice::class, 'amountInCents');
$property->setValue($invoice, -500); // fuehrt den Hook aus, wirft eine Exception
$property->setRawValue($invoice, -500); // schreibt direkt in den Speicher, ohne Hook
Aktuelle Doctrine-ORM-Releases nutzen fuer die Hydration die Raw-Accessoren, und genau dieses Verhalten willst du. Es bedeutet aber auch: Ein Set-Hook ist kein Mechanismus fuer Datenintegritaet. Er schuetzt Codepfade in der Anwendung und tut rein gar nichts fuer Zeilen, die aus der Datenbank kommen. Wenn die Invariante auch fuer Bestandsdaten gelten soll, gehoert sie in eine Migration und einen Datenbank-Constraint, nicht in einen Hook.
Pruefe, welches Verhalten deine Version liefert, bevor du dich auf eines von beiden verlaesst. Pinne die ORM-Version mit einem Test, der eine Entity mit einem Wert hydriert, den der Hook ablehnen wuerde, und stelle sicher, dass die Hydration durchlaeuft. Wenn ein kuenftiges Upgrade den Schreibpfad aendert, schlaegt dieser Test sofort fehl statt waehrend eines naechtlichen Imports.
Virtuelle Properties lassen sich nicht mappen
Ein Hook ohne Backing-Store ergibt eine virtuelle Property. Der Wert wird beim Lesen berechnet, gespeichert wird nichts:
public string $displayName {
get => trim("{$this->firstName} {$this->lastName}");
}
Das passt gut zu einem Read Model oder einem Ausgabefeld im Serializer. Fuer Doctrine ist es nichts, was sich auf eine Spalte mappen laesst, denn es gibt keinen Speicher dahinter, in den geschrieben oder aus dem gelesen werden koennte. Annotierst du sie als Spalte, bekommst du einen Fehler beim Kompilieren der Metadaten, was noch das freundliche Ergebnis ist. Die unfreundliche Variante ist eine virtuelle Property, die einen Namen ueberdeckt, den Doctrine erwartet. Mappe also explizit und halte virtuelle Properties aus dem gemappten Bestand heraus.
Dieselbe Regel betrifft das Change Tracking. Doctrine vergleicht die hydrierten Werte mit den Werten, die es zum Flush-Zeitpunkt vorfindet. Eine virtuelle Property hat keinen hydrierten Wert zum Vergleichen und taucht deshalb nie in einem Changeset auf. Wenn ein berechneter Wert in SQL abfragbar oder sortierbar sein muss, speichere ihn in einer echten Spalte und aktualisiere ihn in einem Lifecycle-Callback oder einer Domaenenmethode.
Hooks, readonly und asymmetrische Sichtbarkeit
Teams greifen oft zu einem Hook, um eine Property write-once zu machen. Dafuer sind Hooks nicht gedacht, und die Engine sagt es dir auch: Eine readonly-Property darf einen get-Hook definieren, ein set-Hook auf einer readonly-Property wird abgelehnt.
PHP 8.4 hat fuer die eigentliche Anforderung ein besseres Werkzeug mitgebracht. Asymmetrische Sichtbarkeit trennt Lesezugriff von Schreibzugriff:
final class Subscription
{
public private(set) string $status;
public function cancel(): void
{
$this->status = 'cancelled';
}
}
Jeder Aufrufer kann $status lesen. Nur die Klasse selbst kann schreiben. Kein Hook, kein Setter, kein Accessor-Boilerplate. Asymmetrische Sichtbarkeit mit einem set-Hook zu kombinieren ist legitim, wenn du sowohl einen engen Schreib-Scope als auch Normalisierung beim Schreiben brauchst. Greife aber zuerst zum Sichtbarkeits-Modifier und ergaenze den Hook nur, wenn der Wert wirklich transformiert werden muss.
Named Arguments statt Fluent Builder
Die andere Haelfte einer modernen PHP-8.4-API sind Named Arguments, und das Pattern, das sie am nuetzlichsten ersetzen, ist der Fluent Builder. Ein Builder existiert vor allem deshalb, weil ein Konstruktor mit acht Parametern an der Aufrufstelle unlesbar ist. Named Arguments loesen das Lesbarkeitsproblem ohne die zusaetzliche Klasse:
$request = new ReportRequest(
from: new DateTimeImmutable('2026-01-01'),
to: new DateTimeImmutable('2026-03-31'),
currency: 'EUR',
includeDrafts: false,
);
Jeder Editor mit PHP Language Server vervollstaendigt diese Parameternamen, und das Objekt ist in dem Moment gueltig, in dem es existiert. Ein Fluent Builder kann das nicht versprechen, denn ->build() muss zur Laufzeit pruefen, ob du jeden erforderlichen Schritt aufgerufen hast.
Fuer unveraenderliche Value Objects deckt eine einzelne with()-Methode ab, wofuer frueher eine Kette von withX()-Methoden noetig war:
public function with(
?DateTimeImmutable $from = null,
?DateTimeImmutable $to = null,
?bool $includeDrafts = null,
): self {
return new self(
from: $from ?? $this->from,
to: $to ?? $this->to,
currency: $this->currency,
includeDrafts: $includeDrafts ?? $this->includeDrafts,
);
}
Ein Vorbehalt, der im Review zuschlaegt: null bedeutet hier "unveraendert lassen", diese Form kann also nicht ausdruecken "setze dieses Feld auf null". Wenn ein Feld wirklich nullable ist, verwende ein Sentinel-Objekt oder behalte fuer dieses Feld eine eigene withoutX()-Methode. So zu tun, als gaebe es die Mehrdeutigkeit nicht, erzeugt eine Klasse von Bugs, die im Diff sehr schwer zu erkennen ist.
Es gibt auch eine API-Vertragsfrage, die man klar benennen sollte. Sobald Aufrufer Named Arguments verwenden, sind deine Parameternamen oeffentlich. $includeDrafts in $withDrafts umzubenennen ist ein Breaking Change fuer jeden Aufrufer, genauso wie das Umbenennen einer oeffentlichen Methode. Behandle eine Parameterumbenennung in einer Library oder einem geteilten Package als Semver-Bump und einige dich auf Namen im Code Review statt nach dem Release. Das ist eine der kleinen Sachen, die ein Code Quality Review guenstig findet und ein Support-Ticket teuer.
Was Hooks zur Laufzeit kosten
Der uebliche Vergleich ist der mit __get und __set, und beide funktionieren recht unterschiedlich. Magic Methods feuern nur, wenn eine Property fehlt oder aus dem aufrufenden Scope nicht zugreifbar ist. Die Engine macht also einen Lookup, geht leer aus und dispatcht dann mit Rekursionsschutz zur Magic Method. Hooks werden als Teil der Property-Deklaration kompiliert, und die Zugriffsstelle weiss zur Compile-Zeit, dass ein Hook beteiligt ist.
Praktisch heisst das: __get durch Hooks zu ersetzen ist meist ein kleiner Gewinn statt einer Regression, und eine einfache Property durch eine gehookte zu ersetzen kostet etwas. Ein gehookter Property-Zugriff ist ein Call, kein Speicherzugriff. In einer heissen Schleife ueber hunderttausend Zeilen macht sich das bemerkbar. In Request-Handling-Code nicht.
Zwei Regeln verhindern, dass daraus ein Problem wird. Halte Hook-Bodies kurz, idealerweise ein Vergleich oder eine Zuweisung, denn alles Laengere ist Domaenenlogik, die sich in einem Accessor versteckt. Und packe keinen Datenbank-Call, keinen Event-Dispatch und keinen Mutation-Log-Write in einen Hook. Event-sourced Aggregates sind hier der verlockende Fall: Jede Mutation in einem set-Hook aufzuzeichnen sieht elegant aus, bis die Hydration die gesamte Historie abspielt und das Aggregate protokolliert, wie es geladen wird. Zeichne Mutationen in expliziten Domaenenmethoden auf, wo die Absicht im Call Stack sichtbar ist.
Statische Analyse nuetzlich halten
PHPStan 2.x versteht gehookte Properties und analysiert die Bodies. Ein Hook, der den falschen Typ zurueckgibt oder einen nicht initialisierten Backing-Wert liest, wird auf dem Level gefunden, das du ohnehin schon faehrst. Zwei Gewohnheiten verbessern das Signal.
Deklariere den Backing-Typ praezise, statt dich darauf zu verlassen, dass der Hook ihn eingrenzt. Eine Property vom Typ ?string mit einem get-Hook, der nie null zurueckgibt, liest sich fuer jeden Aufrufer und fuer den Analyser trotzdem als nullable. Wenn sie nach der Konstruktion nicht null sein kann, typisiere sie als string.
Und vermeide Hooks, die sowohl den Backing-Store schreiben als auch eine andere Property mutieren. Analyser verfolgen solche Faelle schlecht, Reviewer noch schlechter, und die entstehende Kopplung macht die Klasse spaeter schwer refaktorierbar. Wenn du dich durch eine Codebasis arbeitest, in der Accessoren still zu Domaenenlogik angewachsen sind, ist genau dieses Entwirren der Kern von Legacy Code Optimierung.
Eine praktische Einfuehrungsreihenfolge
Wenn du diese Features in eine bestehende Symfony-Anwendung einfuehrst, ist die risikoarme Reihenfolge: Starte mit DTOs und Form Objects, wo nichts das Objekt per Reflection hydriert und ein set-Hook schlicht Validierung nah an den Daten ist. Gehe danach zu Value Objects und nutze Named Arguments, um Builder-Klassen auszumustern. Lass Doctrine Entities aussen vor, bis du einen Hydrationstest hast, und halte virtuelle Properties komplett aus gemappten Klassen heraus.
Diese Reihenfolge ist wichtig, weil die Fehlermodi zunehmend unsichtbarer werden. Ein kaputtes DTO faellt im Unit-Test auf. Eine kaputte Entity faellt um drei Uhr morgens auf, wenn ein Importjob eine Zeile trifft, die seit 2021 niemand angefasst hat.
Wenn du eine PHP-8.4-Migration planst und eine zweite Meinung dazu willst, wo Hooks helfen und wo sie Risiko hinzufuegen: Wir machen diese Arbeit im Rahmen von Custom Software Development und als eigenstaendige Reviews. Schreib an hello@wolf-tech.io oder lies mehr auf wolf-tech.io.

