PHP 8.4 Property Hooks in Symfony: Was sie für Entity-Design, DTOs und Form-Objekte ändern

#PHP 8.4 Property Hooks
Sandor Farkas - Founder & Lead Developer at Wolf-Tech

Sandor Farkas

Gründer & Lead Developer

Experte für Softwareentwicklung und Legacy-Code-Optimierung

PHP 8.4 Property Hooks sind die bedeutendste Ergänzung des Objektmodells der Sprache seit der Einführung typisierter Properties in PHP 8.0. Auf dem Papier sieht das Feature wie eine saubere Quality-of-Life-Verbesserung aus: weniger Boilerplate, sauberere berechnete Werte, keine trivialen Getter/Setter-Paare mehr. In der Praxis, wenn du eine Symfony-Anwendung mit Doctrine ORM betreibst und API Platform für deine API-Schicht verwendest, interagieren Property Hooks mit deinem Stack auf Weisen, die der RFC nicht erklärt und bei denen die Framework-Dokumentation noch hinterherhinkt.

Dieser Post behandelt die drei Patterns, zu denen Teams zuerst greifen, was tatsächlich passiert, wenn jedes davon auf Doctrines Unit of Work, Symfonys PropertyAccessor und den Serializer von API Platform trifft - und die Migrations-Checkliste, die du brauchst, bevor du eine Produktions-Codebasis von PHP 8.3 upgradest.

Was PHP 8.4 Property Hooks eigentlich sind

Eine Property mit Hooks ersetzt das separate Get/Set-Methodenpaar durch Inline-Logik, die direkt an der Property-Deklaration hängt. Ein get-Hook läuft, wenn die Property gelesen wird; ein set-Hook läuft, wenn sie geschrieben wird.

class Money
{
    public int $amountInCents {
        get => $this->amountInCents;
        set(int $value) {
            if ($value < 0) {
                throw new \InvalidArgumentException('Amount cannot be negative.');
            }
            $this->amountInCents = $value;
        }
    }
}

Properties können auch virtuell sein - sie haben einen Hook, aber keinen Backing-Speicher. Eine virtuelle Property mit nur einem get-Hook berechnet ihren Wert bei Bedarf, ohne dass ein Feld dahintersteht:

class FullName
{
    public string $full {
        get => trim("{$this->first} {$this->last}");
    }

    public function __construct(
        public string $first,
        public string $last,
    ) {}
}

Das ist die Syntax. Schauen wir uns jetzt an, was passiert, wenn sie auf eine Symfony-plus-Doctrine-Codebasis trifft.

Pattern 1: Berechnete Properties auf Doctrine-Entities

Die Versuchung ist offensichtlich. Du hast eine Product-Entity mit priceNet und taxRate als gespeicherten Spalten. Du willst priceGross als on-the-fly berechneten Lesewert. Früher hast du eine Methode geschrieben; jetzt willst du eine virtuelle Property.

#[ORM\Entity]
class Product
{
    #[ORM\Column]
    public float $priceNet;

    #[ORM\Column]
    public float $taxRate;

    public float $priceGross {
        get => $this->priceNet * (1 + $this->taxRate);
    }
}

Überlebt das einen Doctrine-Flush? Ja - mit einem wichtigen Vorbehalt. Doctrines Metadata-Driver ignoriert Properties, für die kein Mapping existiert, $priceGross wird also von der Unit of Work schlicht nicht getrackt. $product->priceGross zu lesen funktioniert problemlos. Ein Flush von $product versucht nicht, $priceGross zu persistieren. So weit, so gut.

Das Problem zeigt sich bei Doctrines Lazy-Loading-Proxies. Wenn Doctrine eine Proxy-Klasse für Product generiert, muss es jede Property überschreiben, die an seinem Interception-Mechanismus teilnimmt. Properties mit Hooks können nicht auf dieselbe Weise in einer Subklasse überschrieben werden wie einfache Properties - der Proxy-Generator in Doctrine-ORM-Versionen vor 3.3 versteht die Hook-Syntax nicht und erzeugt ungültiges PHP, was beim ersten Zugriff zu einem Fatal Error führt.

Wenn du auf Doctrine ORM 3.3 oder später bist, behandelt die Proxy-Generierung Properties mit Hooks korrekt. Auf einem früheren Release ist der Workaround, berechnete Werte als reguläre Methoden zu belassen und zu warten, bis deine Dependency-Constraints das Upgrade erlauben.

Fazit: Sicher nutzbar für schreibgeschützte berechnete Properties auf Entities, wenn du auf Doctrine ORM ≥ 3.3 bist. Verwende keine Hooks mit Backing-Field auf gemappten Spalten - Doctrines Change-Tracking wird gegen dich arbeiten.

Pattern 2: Validierungslogik in DTOs

Data Transfer Objects sind ein naheliegender Kandidat für set-Hooks. Statt Assert-Attribute zu setzen und den Validator extern laufen zu lassen, möchtest du Invarianten vielleicht direkt bei der Zuweisung erzwingen:

class CreateOrderInput
{
    public string $email {
        set(string $value) {
            if (!filter_var($value, FILTER_VALIDATE_EMAIL)) {
                throw new \InvalidArgumentException("Invalid email: {$value}");
            }
            $this->email = $value;
        }
    }
}

Das funktioniert korrekt, wenn du das DTO manuell instanziierst und Werte im Code zuweist. Wo es bricht, ist bei Symfonys PropertyAccessor - der Komponente, die Symfony Forms und der Serializer intern nutzen, um Objekte aus eingehenden Daten zu hydrieren.

PropertyAccessor löst Schreibpfade aktuell mit dieser Priorität auf: public Property → Setter-Methode → Konstruktor. Stand Symfony 7.1 ruft er beim Schreiben in eine Property keine set-Hooks auf - er schreibt direkt in den Backing-Speicher und umgeht deinen Hook vollständig.

Das bedeutet: Deine Validierungslogik tut stillschweigend nichts, wenn ein Formular abgesendet wird oder wenn API Platform einen Request-Body in dein DTO deserialisiert. Der Hook-Code existiert; er wird von der Framework-Schicht einfach nie aufgerufen.

Symfony 7.2 hat PropertyAccessor-Unterstützung für Property Hooks eingeführt, über die DISALLOW_PUBLIC_PROPERTY_ACCESS-Strategie kombiniert mit einem neuen Hook-fähigen Schreibpfad. Wenn du auf Symfony 7.2 oder später bist und PropertyAccessor mit dieser Strategie konfigurierst, werden set-Hooks während der Formular-Hydrierung und Serializer-Deserialisierung aufgerufen.

Auf Symfony 7.0 oder 7.1 sind set-Hooks auf DTOs, die mit Forms oder API Platform verwendet werden, für Validierung ein stilles No-op. Verwende stattdessen #[Assert\Email]-Attribute und die Symfony-Validator-Komponente - sie funktionieren unabhängig von PHP- oder Symfony-Version.

Fazit: Für DTO-Validierung nur in Symfony 7.2+ verlässlich. Auf früheren Versionen umgeht Symfonys Form- und Serializer-Stack die set-Hooks. Nutze Constraint-Attribute und den Validator auf jeder Version als primären Validierungsmechanismus.

Pattern 3: Form-Objekte ohne Backing-Field

Das dritte Pattern entfernt das Backing-Field vollständig und nutzt den set-Hook, um Schreibzugriffe an eine andere Property oder externen Speicher weiterzuleiten:

class RegistrationForm
{
    private string $rawPassword = '';

    public string $password {
        get => '';  // never expose
        set(string $value) {
            $this->rawPassword = password_hash($value, PASSWORD_BCRYPT);
        }
    }

    public function getRawPassword(): string
    {
        return $this->rawPassword;
    }
}

Die Absicht ist nachvollziehbar - beim Zuweisen hashen, nie Klartext speichern. Das Problem hier: Symfonys FormType-System liest beim Rendern des Formulars die Initialwerte aus dem gebundenen Datenobjekt. Wenn get einen leeren String zurückgibt (wie im Beispiel oben), rendert das Feld korrekt. Aber wenn PropertyAccessor den übermittelten Wert zurückschreibt, greift auf Symfony < 7.2 dasselbe Bypass-Problem wie in Pattern 2.

Es gibt ein zweites Problem, das spezifisch für Properties ohne Backing-Field ist: PHP erlaubt es nicht, eine Property ohne Backing-Field zu deklarieren und dann innerhalb des set-Hooks $this->propertyName = $value zuzuweisen - das erzeugt eine Endlosschleife. Das Pattern oben umgeht das, indem es stattdessen in $rawPassword schreibt, was korrekt ist. Aber Teams versuchen oft den einfacheren Forwarding-Ansatz und laufen sofort in unendliche Rekursion.

Außerdem sind Symfonys PasswordHasherType und der bestehende PasswordType bereits für genau diesen Anwendungsfall gebaut und integrieren sich sauber mit der Security-Schicht. Eine eigene Hook-basierte Lösung zu bauen reimplementiert, was diese Typen für dich erledigen, inklusive der "nicht neu hashen, wenn unverändert"-Logik.

Fazit: Technisch möglich, aber fragil in Symfony < 7.2, und angesichts der vorhandenen Symfony-Form-Feldtypen weitgehend unnötig. Nutze dieses Pattern nur, wenn du einen starken Grund hast, die Hashing-Logik selbst zu besitzen, und nur auf Symfony 7.2+.

Wo API Platform ins Spiel kommt

Die Serialisierungsschicht von API Platform nutzt die Symfony-Serializer-Komponente, die wiederum PropertyAccessor verwendet. Dieselbe Symfony-Versionsbeschränkung gilt: Auf Symfony < 7.2 ruft der Serializer während der Deserialisierung keine set-Hooks auf. Das betrifft PATCH- und POST-Requests, bei denen API Platform dein Input-DTO oder deine Entity aus dem Request-Body befüllt.

API Platform 3.3 (kompatibel mit Symfony 7.2) bringt explizite Hook-Unterstützung in seiner Metadata-Extraction-Schicht mit. Es liest Hook-Deklarationen beim Aufbau der API-Resource-Metadaten, was bedeutet, dass du eine berechnete Property mit nur einem get-Hook als serialisiertes Feld exponieren kannst, ohne eine separate Getter-Methode zu brauchen. Das ist ein echter ergonomischer Gewinn für Read-Models.

#[ApiResource]
#[ORM\Entity]
class Invoice
{
    #[ORM\Column]
    public float $subtotal;

    #[ORM\Column]
    public float $vatRate;

    #[ApiProperty(readable: true, writable: false)]
    public float $total {
        get => round($this->subtotal * (1 + $this->vatRate), 2);
    }
}

Auf API Platform 3.3 + Symfony 7.2 + Doctrine ORM 3.3 funktioniert diese Kombination ohne zusätzliche Konfiguration. Auf früheren Versionen markiere den berechneten Wert stattdessen mit einer #[SerializedName]-Annotation auf einer echten Getter-Methode.

Einordnung in die Modernisierungsstrategie

Wenn dein Team gerade evaluiert, ob PHP 8.4 als Teil eines breiteren Modernisierungsschubs eingeführt werden soll, gehören die obigen Überlegungen in eine umfassendere Entscheidung zur Legacy-Code-Modernisierung. In unserer Arbeit im Bereich individuelle Softwareentwicklung helfen wir Teams regelmäßig dabei, genau zu identifizieren, wo ein Sprach-Upgrade sofortigen Mehrwert schafft und wo es Risiken einführt, die zuerst gemanagt werden müssen.

Migrations-Checkliste: PHP 8.3 → PHP 8.4 in einer Symfony-Codebasis

Geh diese Liste durch, bevor du die PHP-Version in Produktion umstellst.

Vor dem Upgrade:

  • Bestätige, dass Doctrine ORM auf 3.3 oder später ist (composer show doctrine/orm). Falls nicht, upgrade zuerst Doctrine.
  • Bestätige, dass Symfony auf 7.2 oder später ist, wenn du Property Hooks in DTOs oder Form-Objekten nutzen willst. Wenn du auf 7.0 oder 7.1 bleibst, sind Property Hooks nur für berechnete Entity-Properties sicher.
  • Auditier alle Doctrine-Entity-Klassen auf bestehende Getter/Setter-Paare auf gemappten Spalten. Ersetze diese nicht durch Properties mit Hooks, ohne die Proxy-Generierung in einer Staging-Umgebung zu verifizieren.
  • Führe nach dem Upgrade php bin/console doctrine:generate:proxies aus (oder das Äquivalent für deine Proxy-Cache-Strategie) und prüfe auf PHP Fatal Errors, bevor du testest.

Während des Upgrades:

  • Upgrade in einem Branch. Deploye einen PHP-Versions-Bump nicht zusammen mit Feature-Arbeit.
  • Aktiviere PHP 8.4 zuerst auf einem einzelnen Staging-Server. Lass deine komplette Testsuite laufen. Achte auf jeden Test, der Entities instanziiert und über PropertyAccessor in sie schreibt - das sind die Tests, die den set-Hook-Bypass aufdecken.
  • Nutze php -l auf deinem gesamten src/-Verzeichnis, um Syntaxprobleme vor dem Deployment zu erwischen.

Nach dem Upgrade:

  • Führe Properties mit Hooks schrittweise ein, beginnend mit schreibgeschützten berechneten Werten auf Nicht-Entity-Klassen. Diese sind risikofrei.
  • Führe set-Hooks auf DTOs und Form-Objekten erst ein, nachdem du die Symfony-7.2-Kompatibilität Ende-zu-Ende bestätigt hast.
  • Aktualisiere dateModified in deinem Blog und deiner Dokumentation, um das Upgrade-Datum widerzuspiegeln - Google nutzt Freshness-Signale beim Ranking technischer Inhalte.

Die Kurzfassung

PHP 8.4 Property Hooks sind wirklich nützlich, aber die Interaktionsfläche in einem Symfony-plus-Doctrine-Stack ist spezifisch. Schreibgeschützte berechnete Properties auf Entities sind auf Doctrine ORM 3.3+ sicher. Set-Hooks für Validierung oder kontrollierte Schreibzugriffe erfordern Symfony 7.2 und die aktualisierte PropertyAccessor-Strategie. API Platform gewinnt ergonomische Metadata-Unterstützung in 3.3. Wenn dein Stack unter diesen Versionen liegt, werden Hooks entweder stillschweigend umgangen oder brechen die Proxy-Generierung.

Wenn du ein PHP-8.4-Upgrade planst und ein zweites Paar Augen auf die Risikofläche willst, bevor du dich festlegst, melde dich unter hello@wolf-tech.io oder buche einen kostenlosen technischen Review auf wolf-tech.io. Wir haben diese Upgrades auf Produktions-Codebasen durchgeführt, von kleinen SaaS-Anwendungen bis zu Enterprise-Systemen mit 18 Jahren angesammelter Doctrine-Mappings - die Risikopunkte sind bekannt und mit der richtigen Reihenfolge beherrschbar.