Wann der eingebaute ObjectNormalizer an seine Grenzen stößt und wie man ihn gezielt ergänzt
Der ObjectNormalizer der Symfony Serializer-Komponente liest per Reflection alle Properties eines Objekts aus und baut daraus ein Array, was für einfache DTOs hervorragend funktioniert. Sobald ein Objekt aber ein Value Object mit eigener interner Struktur ist, eine polymorphe Klassenhierarchie abbildet oder Objekte referenziert, die wiederum aufeinander zeigen, stößt diese generische Reflection-Logik an klare Grenzen. Dieser Artikel zeigt, wie man mit NormalizerInterface und DenormalizerInterface eigene Transformationen schreibt, wie man deren Priorität gegenüber eingebauten Normalizern steuert und wie man Zirkelreferenzen kontrolliert auflöst, statt in eine Endlosschleife zu laufen.
Inhaltsverzeichnis
- 1. Warum der eingebaute ObjectNormalizer nicht immer reicht
- 2. NormalizerInterface und DenormalizerInterface im Detail
- 3. supportsNormalization() und Priorität gegenüber eingebauten Normalizern
- 4. Polymorphe Objekte mit einem Discriminator sauber normalisieren
- 5. Value Objects kompakt statt als verschachteltes Array serialisieren
- 6. Zirkelreferenzen erkennen und mit dem CircularReferenceHandler auflösen
- 7. Den inneren Serializer mit NormalizerAwareInterface wiederverwenden
- 8. Denormalisierung: Fehlerbehandlung und Zusammenspiel mit Validierung
- 9. Eigene Normalizer testen und in der Praxis einbinden
- 10. Zusammenfassung
- 11. FAQ
1. Warum der eingebaute ObjectNormalizer nicht immer reicht
Der ObjectNormalizer arbeitet generisch: Er iteriert per Reflection oder über einen PropertyAccessor durch alle öffentlichen Properties und Getter eines Objekts und baut daraus rekursiv ein Array auf. Das funktioniert zuverlässig für flache DTOs mit skalaren Feldern, stößt aber an Grenzen, sobald die gewünschte JSON-Repräsentation strukturell von der internen Objektstruktur abweicht. Ein Money-Value-Object mit den internen Feldern amount und currency soll vielleicht als einzelner String '12.50 EUR' ausgegeben werden, und dafür bietet der ObjectNormalizer keinen sauberen Hebel, außer man verbiegt die Objektstruktur selbst um die Serialisierung willen.
Noch deutlicher wird das Problem bei polymorphen Objekthierarchien, etwa verschiedenen Zahlungsarten, die alle ein gemeinsames Interface implementieren, aber unterschiedliche Felder besitzen. Der generische Normalizer weiß nichts von einem Diskriminator-Feld, das dem Client mitteilt, um welchen konkreten Typ es sich handelt, und bei der Denormalisierung kann er ohne zusätzliche Konfiguration erst recht nicht entscheiden, welche konkrete Klasse aus einem eingehenden JSON-Objekt instanziiert werden soll. Genau für diese Fälle bietet die Serializer-Komponente die beiden zentralen Erweiterungspunkte NormalizerInterface und DenormalizerInterface an, die man gezielt für einzelne Klassen implementiert, ohne die generische Behandlung aller anderen Objekte anzutasten.
2. NormalizerInterface und DenormalizerInterface im Detail
Ein eigener Normalizer implementiert normalize(), das aus dem Objekt ein serialisierbares Array oder einen Skalar erzeugt, und supportsNormalization(), das dem Serializer mitteilt, ob dieser Normalizer für das konkrete Datum zuständig ist. Seit Symfony 6.3 kommt zusätzlich getSupportedTypes() hinzu, das eine Zuordnung von Klassennamen zu einem Boolean oder null zurückgibt und dem Serializer erlaubt, die Zuständigkeit zu cachen, statt supportsNormalization() bei jedem Aufruf erneut auszuführen. Der Denormalizer spiegelt das mit denormalize() und supportsDenormalization(), wobei denormalize() aus rohen Array-Daten wieder ein typisiertes Objekt erzeugt.
Das folgende Beispiel zeigt einen kombinierten Normalizer und Denormalizer für ein unveränderliches Money-Value-Object, das intern einen Integer-Betrag in kleinster Währungseinheit und einen ISO-Währungscode hält. Wichtig ist dabei, dass beide Interfaces unabhängig voneinander implementiert werden können: Man kann einen reinen Normalizer ohne Denormalizer schreiben, wenn ein Objekt nur ausgehend serialisiert, aber nie aus einem Request wieder aufgebaut werden muss, etwa bei einem berechneten Wert wie einer Gesamtsumme.
<?php
declare(strict_types=1);
namespace App\Serializer\Normalizer;
use App\ValueObject\Money;
use Symfony\Component\Serializer\Normalizer\DenormalizerInterface;
use Symfony\Component\Serializer\Normalizer\NormalizerInterface;
final class MoneyNormalizer implements NormalizerInterface, DenormalizerInterface
{
public function normalize(mixed $data, ?string $format = null, array $context = []): array
{
if (!$data instanceof Money) {
throw new \InvalidArgumentException('Expected instance of Money.');
}
return [
'amount' => $data->getAmount(),
'currency' => $data->getCurrency(),
];
}
public function supportsNormalization(mixed $data, ?string $format = null, array $context = []): bool
{
return $data instanceof Money;
}
public function denormalize(mixed $data, string $type, ?string $format = null, array $context = []): Money
{
return new Money((int) $data['amount'], (string) $data['currency']);
}
public function supportsDenormalization(mixed $data, string $type, ?string $format = null, array $context = []): bool
{
return $type === Money::class;
}
public function getSupportedTypes(?string $format): array
{
return [Money::class => true];
}
}
3. supportsNormalization() und Priorität gegenüber eingebauten Normalizern
Symfony registriert Normalizer als Services mit dem Tag serializer.normalizer und ermittelt bei jeder Normalisierung den zuständigen Normalizer, indem es alle registrierten Normalizer der Reihe nach nach ihrer Priorität fragt, ob sie supportsNormalization() für das aktuelle Datum bejahen. Der erste Normalizer, der zustimmt, gewinnt, und das bedeutet in der Praxis, dass ein eigener Normalizer eine höhere Priorität als der eingebaute ObjectNormalizer bekommen muss, wenn er dessen Standardverhalten für eine bestimmte Klasse überschreiben soll. Ohne explizite Priorität landet ein eigener Service oft zufällig hinter dem ObjectNormalizer in der Kette, sodass die eigene Logik nie zum Zug kommt.
Die Priorität setzt man entweder über das Attribut #[AutoconfigureTag('serializer.normalizer', ['priority' => 100])] direkt an der Klasse oder über den priority-Schlüssel im Tag in services.yaml. Eine höhere Zahl bedeutet dabei frühere Prüfung. Wichtig ist, supportsNormalization() möglichst eng zu fassen, etwa mit einem strikten instanceof-Check, damit der eigene Normalizer nicht versehentlich für Objekte zuständig wird, die eigentlich vom generischen ObjectNormalizer behandelt werden sollen. Ein zu breiter Support-Check kann sonst dazu führen, dass unerwartete Klassen plötzlich falsch oder unvollständig serialisiert werden, was in der Praxis oft erst durch einen fehlerhaften API-Response beim Kunden auffällt.
4. Polymorphe Objekte mit einem Discriminator sauber normalisieren
Bei einer Klassenhierarchie wie PaymentMethod mit den konkreten Implementierungen CreditCardPayment und PaypalPayment reicht ein einfacher instanceof-Check im Normalizer meist aus, um pro konkreter Klasse ein zusätzliches Feld type in das Ergebnis-Array zu schreiben, etwa 'type' => 'credit_card'. Für die Denormalisierung ist die Aufgabe umgekehrt schwieriger, denn aus rohen Array-Daten muss zunächst der type-Wert gelesen werden, bevor entschieden werden kann, welche konkrete Klasse überhaupt instanziiert wird. Symfony bietet dafür seit einigen Versionen auch die eingebaute ClassDiscriminatorMapping über das Attribut DiscriminatorMap, die genau diese Zuordnung deklarativ beschreibt, ohne dass man dafür zwingend einen eigenen Denormalizer schreiben muss.
Sobald die Transformation aber komplexer ist als eine reine Typzuordnung, etwa weil bestimmte Felder je nach konkretem Typ unterschiedlich validiert oder mit Default-Werten befüllt werden müssen, lohnt sich weiterhin ein eigener Denormalizer, der intern den type-Wert liest und die eigentliche Objekterzeugung an den passenden inneren Denormalizer per Delegation weiterreicht. Diese Kombination aus DiscriminatorMap für die reine Typermittlung und einem eigenen Normalizer für die feinere Transformationslogik ist in der Praxis der robusteste Ansatz für wachsende, polymorphe Domänenmodelle, weil sich beide Mechanismen unabhängig voneinander weiterentwickeln lassen.
5. Value Objects kompakt statt als verschachteltes Array serialisieren
Value Objects wie Money, EmailAddress oder DateRange kapseln bewusst Invarianten und interne Struktur, die für den API-Konsumenten meist irrelevant ist. Ohne eigenen Normalizer würde der ObjectNormalizer aus einem EmailAddress-Objekt mit dem internen Feld value ein verschachteltes Array wie {'value': 'kunde@example.com'} erzeugen, obwohl ein API-Client naturgemäß einfach den String 'kunde@example.com' erwartet. Ein eigener Normalizer kann diese Verschachtelung auflösen, indem normalize() direkt den Skalar statt eines Arrays zurückgibt, was die Serializer-Komponente ausdrücklich erlaubt, solange der gewählte Ausgabe-Format (JSON, XML) den Skalar korrekt abbilden kann.
Bei der Denormalisierung gilt die Umkehrung: Der Denormalizer nimmt den eingehenden Skalar entgegen und konstruiert daraus das Value Object, wobei genau an dieser Stelle die im Value Object hinterlegte Validierungslogik greifen sollte, etwa ein Format-Check für die E-Mail-Adresse im Konstruktor. Dieses Muster hat den zusätzlichen Vorteil, dass ungültige Werte bereits während der Denormalisierung als Exception auffliegen, lange bevor die eigentliche Business-Logik der Anwendung mit einem inkonsistenten Objekt arbeiten würde, was Fehlerursachen deutlich näher an ihrem Ursprung sichtbar macht.
6. Zirkelreferenzen erkennen und mit dem CircularReferenceHandler auflösen
Referenzieren sich zwei Objekte gegenseitig, etwa ein Order-Objekt mit einer Referenz auf seinen Customer und ein Customer-Objekt mit einer Liste seiner Orders, läuft der Serializer ohne Gegenmaßnahme in eine Endlosschleife, weil er beim Normalisieren des Customers erneut versucht, die Order zu normalisieren, die wiederum den Customer normalisieren will. Symfony erkennt solche Zyklen automatisch anhand der Objekt-Identität innerhalb eines Normalisierungsdurchlaufs und wirft standardmäßig eine CircularReferenceException, sobald ein Objekt zum zweiten Mal in derselben Verschachtelungskette auftaucht.
Statt die Exception einfach durchzureichen, konfiguriert man üblicherweise einen circular_reference_handler im Serializer-Kontext, ein Callable, das anstelle der vollen Normalisierung einen Ersatzwert liefert, meist die ID des betroffenen Objekts. Dieser Handler lässt sich global in der Serializer-Konfiguration hinterlegen oder pro Aufruf über den context-Parameter von serialize() setzen, was besonders bei API-Endpunkten sinnvoll ist, die je nach Anwendungsfall unterschiedlich tief verschachtelte Antworten liefern sollen. Wichtig ist, den Handler bewusst zu wählen: Die reine ID reicht für die meisten API-Antworten, während manche Anwendungsfälle stattdessen ein komplett leeres Array oder null bevorzugen, um dem Client explizit zu signalisieren, dass an dieser Stelle keine Detaildaten geliefert werden.
7. Den inneren Serializer mit NormalizerAwareInterface wiederverwenden
Ein eigener Normalizer muss nicht jedes verschachtelte Objekt manuell normalisieren, sondern kann für Teilbereiche den eigentlichen Serializer-Service wiederverwenden, indem die Klasse NormalizerAwareInterface implementiert und den NormalizerAwareTrait einbindet. Dadurch erhält die Klasse Zugriff auf $this->normalizer, also den vollständig konfigurierten Serializer inklusive aller anderen registrierten Normalizer, und kann für verschachtelte Objekte einfach $this->normalizer->normalize($nestedObject, $format, $context) aufrufen, statt die Normalisierungslogik dafür zu duplizieren.
Dieses Muster ist besonders wertvoll, wenn ein eigener Normalizer nur ein einzelnes zusätzliches Feld ergänzen oder ein einzelnes Feld umbenennen soll, während der Rest des Objekts weiterhin durch den ObjectNormalizer behandelt werden soll. In diesem Fall injiziert man zusätzlich den konkreten ObjectNormalizer als Delegat, ruft dessen normalize()-Methode für die Basisstruktur auf und reichert das Ergebnis-Array anschließend mit den zusätzlichen Feldern an. So bleibt die eigene Logik minimal und robust gegenüber Änderungen an anderen Feldern der Ursprungsklasse, die man sonst bei einer vollständig manuellen Implementierung leicht vergisst nachzuziehen.
8. Denormalisierung: Fehlerbehandlung und Zusammenspiel mit Validierung
Bei der Denormalisierung eingehender Requests sollte ein eigener Denormalizer klar zwischen strukturellen Fehlern und fachlichen Validierungsfehlern unterscheiden. Fehlt ein Pflichtfeld im eingehenden Array oder hat es den falschen Typ, ist das ein struktureller Fehler, den man idealerweise als NotNormalizableValueException wirft, eine spezielle Exception-Klasse der Serializer-Komponente, die der aufrufende Code gezielt abfangen und in eine strukturierte 400-Antwort übersetzen kann. Fachliche Regeln wie ein zu niedriger Betrag oder ein unplausibles Datum gehören dagegen eher in die Symfony Validator-Komponente, die nach der Denormalisierung auf dem fertigen Objekt läuft.
Diese Trennung hat einen praktischen Grund: Der Denormalizer soll robust ein Objekt konstruieren können, sofern die Rohdaten strukturell plausibel sind, während die eigentliche fachliche Prüfung zentral und wiederverwendbar über Validator-Constraints erfolgt, die sich unabhängig von der Serialisierung auch auf Formular-Objekten oder direkt erzeugten Objekten anwenden lassen. Wer fachliche Validierung direkt im Denormalizer verankert, verliert diese Wiederverwendbarkeit und muss die Regeln bei jeder neuen Eingabequelle, etwa einem CLI-Import, erneut duplizieren, was Wartungsaufwand über die Zeit unnötig erhöht.
9. Eigene Normalizer testen und in der Praxis einbinden
Ein eigener Normalizer lässt sich isoliert und ohne den vollen Serializer-Container testen, weil er eine gewöhnliche Klasse mit klar definierten Methoden ist. Ein PHPUnit-Test instanziiert den Normalizer direkt, ruft normalize() mit einem konkreten Objekt auf und prüft das resultierende Array gegen die erwartete Struktur, und ein entsprechender Test für denormalize() prüft den umgekehrten Weg inklusive der Fälle, in denen ungültige Rohdaten zur erwarteten Exception führen müssen. Diese Tests laufen deutlich schneller als Integrationstests über den vollen Serializer-Service und decken trotzdem die eigentliche Kernlogik zuverlässig ab.
Für die Registrierung im Container reicht bei aktivierter Autoconfiguration in der Regel das automatische Tagging, sofern die Klasse eines der beiden Interfaces implementiert, denn Symfony erkennt serializer.normalizer und serializer.encoder automatisch anhand des implementierten Interfaces. Nur wenn eine explizite Priorität oder eine Einschränkung auf bestimmte Formate benötigt wird, ist eine manuelle Tag-Konfiguration in services.yaml oder ein zusätzliches Attribut an der Klasse nötig. In großen Projekten mit vielen eigenen Normalizern empfiehlt es sich, eine Namenskonvention wie das Suffix Normalizer konsequent einzuhalten, damit im Team sofort erkennbar ist, welche Klassen an der Serializer-Pipeline beteiligt sind.
| Aspekt | ObjectNormalizer (Standard) | Eigener Normalizer |
|---|---|---|
| Arbeitsweise | Reflection über alle Properties/Getter | Explizite, manuell definierte Transformation |
| Polymorphe Typen | Kein eingebautes Konzept für Diskriminatoren | Discriminator-Feld gezielt selbst gesetzt |
| Value Objects | Erzeugt verschachteltes Array | Kann auf einen Skalar reduzieren |
| Zirkelreferenzen | Wirft Exception ohne Handler | Handler individuell konfigurierbar |
| Performance | Reflection-Overhead pro Property | Direkter Zugriff, meist schneller |
Mironsoft
Symfony-Architektur, saubere Domänenlogik und Legacy-Modernisierung
Symfony-Anwendungen, die nach zwei Jahren noch wartbar sind?
Wir prüfen bestehende Symfony-Projekte auf aufgeblähte Controller, fehlende Serviceabstraktionen und ungetestete Kernlogik und bauen daraus eine Architektur, die neue Features aufnimmt, ohne bei jedem Release brüchiger zu werden.
Architektur-Review
Bundle-Struktur, Dependency Injection und Serviceabstraktionen auf Wartbarkeit prüfen.
Legacy-Modernisierung
Schrittweise Migration veralteter Symfony-Versionen ohne kompletten Neuschrieb.
Test- und Qualitätssicherung
PHPUnit, PHPStan und CI-Pipelines für nachhaltige Codequalität aufbauen.
10. Zusammenfassung
Eigene Normalizer und Denormalizer: Das Wichtigste auf einen Blick
Interfaces
NormalizerInterface und DenormalizerInterface für eigene, gezielte Transformationen.
Priorität
Höhere Priorität als der ObjectNormalizer nötig, um dessen Verhalten zu überschreiben.
Zirkelreferenzen
Über circular_reference_handler im Kontext kontrolliert auflösen statt Exception.
Testbarkeit
Normalizer sind einfache Klassen und lassen sich isoliert per PHPUnit prüfen.