PHP 8.4 Named Arguments und Property Hooks: Fortgeschrittene Patterns jenseits der Grundlagen

Sandor Farkas
Gründer & Lead Developer
Experte für Softwareentwicklung und Legacy-Code-Optimierung
LinkedInDie meisten Teams, die PHP 8.4 eingeführt 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 früherer Beitrag zu Property Hooks in Symfony behandelt ihn. Dieser Beitrag geht jenseits dieser Grundlagen und zeigt fortgeschrittene Patterns für Named Arguments und Property Hooks.
Die schwierigeren Fragen kommen später, meist dann, wenn Doctrine Property Hooks zum ersten Mal auf eine Tabelle mit fünf Jahren Historie treffen. Die Hydration läuft nicht durch deinen Konstruktor. Eine Validierung, die du letzte Woche ergänzt hast, kann plötzlich für 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 lässt.
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 Domänenlogik auslöst. Mit einfachen Properties ist das unkompliziert. Mit gehookten Properties nicht, denn PHP 8.4 hat verändert, 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 führt ReflectionProperty::setValue() den Set-Hook aus. Für die meisten Aufrufer ist das der richtige Default, und für ein ORM ist es genau falsch. Um den Backing-Wert ohne Hook zu schreiben, hat PHP 8.4 ein Raw-Paar ergänzt:
$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 für die Hydration die Raw-Accessoren, und genau dieses Verhalten willst du. Es bedeutet aber auch: Ein Set-Hook ist kein Mechanismus für Datenintegrität. Er schützt Codepfade in der Anwendung und tut rein gar nichts für Zeilen, die aus der Datenbank kommen. Wenn die Invariante auch für Bestandsdaten gelten soll, gehört sie in eine Migration und einen Datenbank-Constraint, nicht in einen Hook.
Prüfe, welches Verhalten deine Version liefert, bevor du dich auf eines von beiden verlässt. Pinne die ORM-Version mit einem Test, der eine Entity mit einem Wert hydriert, den der Hook ablehnen würde, und stelle sicher, dass die Hydration durchläuft. Wenn ein künftiges Upgrade den Schreibpfad ändert, schlägt dieser Test sofort fehl statt während eines nächtlichen 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. Für Doctrine ist es nichts, was sich auf eine Spalte mappen lässt, denn es gibt keinen Speicher dahinter, in den geschrieben oder aus dem gelesen werden könnte. 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 überdeckt, 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 Domänenmethode.
Hooks, readonly und asymmetrische Sichtbarkeit
Teams greifen oft zu einem Hook, um eine Property write-once zu machen. Dafür 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 für 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 ergänze den Hook nur, wenn der Wert wirklich transformiert werden muss.
Named Arguments statt Fluent Builder
Die andere Hälfte einer modernen PHP-8.4-API sind Named Arguments, und das Pattern, das sie am nützlichsten ersetzen, ist der Fluent Builder. Ein Builder existiert vor allem deshalb, weil ein Konstruktor mit acht Parametern an der Aufrufstelle unlesbar ist. Named Arguments lösen das Lesbarkeitsproblem ohne die zusätzliche 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 vervollständigt diese Parameternamen, und das Objekt ist in dem Moment gültig, in dem es existiert. Ein Fluent Builder kann das nicht versprechen, denn ->build() muss zur Laufzeit prüfen, ob du jeden erforderlichen Schritt aufgerufen hast.
Für unveränderliche Value Objects deckt eine einzelne with()-Methode ab, wofür früher eine Kette von withX()-Methoden nötig 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 zuschlägt: null bedeutet hier "unverändert lassen", diese Form kann also nicht ausdrücken "setze dieses Feld auf null". Wenn ein Feld wirklich nullable ist, verwende ein Sentinel-Objekt oder behalte für dieses Feld eine eigene withoutX()-Methode. So zu tun, als gäbe 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 öffentlich. $includeDrafts in $withDrafts umzubenennen ist ein Breaking Change für jeden Aufrufer, genauso wie das Umbenennen einer öffentlichen 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 günstig findet und ein Support-Ticket teuer.
Was Hooks zur Laufzeit kosten
Der übliche 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 weiß zur Compile-Zeit, dass ein Hook beteiligt ist.
Praktisch heißt 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 heißen Schleife über 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 Längere ist Domänenlogik, 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 Domänenmethoden auf, wo die Absicht im Call Stack sichtbar ist.
Statische Analyse nützlich halten
PHPStan 2.x versteht gehookte Properties und analysiert die Bodies. Ein Hook, der den falschen Typ zurückgibt oder einen nicht initialisierten Backing-Wert liest, wird auf dem Level gefunden, das du ohnehin schon fährst. Zwei Gewohnheiten verbessern das Signal.
Deklariere den Backing-Typ präzise, statt dich darauf zu verlassen, dass der Hook ihn eingrenzt. Eine Property vom Typ ?string mit einem get-Hook, der nie null zurückgibt, liest sich für jeden Aufrufer und für 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 Fälle schlecht, Reviewer noch schlechter, und die entstehende Kopplung macht die Klasse später schwer refaktorierbar. Wenn du dich durch eine Codebasis arbeitest, in der Accessoren still zu Domänenlogik angewachsen sind, ist genau dieses Entwirren der Kern von Legacy Code Optimierung.
Eine praktische Einführungsreihenfolge
Wenn du diese Features in eine bestehende Symfony-Anwendung einführst, 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 außen 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 fällt im Unit-Test auf. Eine kaputte Entity fällt 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 hinzufügen: Wir machen diese Arbeit im Rahmen von Custom Software Development und als eigenständige Reviews. Schreib an hello@wolf-tech.io oder lies mehr auf wolf-tech.io.
