SF
{ }
Symfony · Serializer · DTO · Normalisierung
Symfony Serializer:
DTO-Mapping und Normalisierung

JSON-Daten manuell aus Request-Arrays zu extrahieren und in PHP-Objekte zu mappen ist fehleranfällig und nicht skalierbar. Der Symfony Serializer normalisiert Objekte in JSON und denormalisiert JSON in typsichere PHP-DTOs – mit Serialization Groups, Custom Normalizern und Constructor-Promotion.

16 Min. Lesezeit ObjectNormalizer · Serialization Groups · Custom Normalizer · DTO-Pipeline Symfony 7.x · PHP 8.4 · Serializer Component

1. Warum Symfony Serializer statt json_encode

json_encode($entity) ist in PHP der naheliegende Weg, ein Objekt in JSON umzuwandeln. Er funktioniert – bis zu dem Moment, in dem man ein Doctrine-Entity serialisiert, das zirkuläre Referenzen oder lazy-geladene Collections enthält. Dann bricht entweder die Serialisierung ab oder es werden Hunderte von Datenbankzeilen in die Antwort eingeschlossen, weil das ORM alle Relationen nachlädt. Der Symfony Serializer löst dieses Problem durch Normalizer-Klassen, die kontrolliert bestimmen, welche Felder in welchem Kontext serialisiert werden.

Der zweite Vorteil des Symfony Serializers liegt in der Richtungsumkehr: Nicht nur Objekte nach JSON, sondern auch JSON zurück in PHP-Objekte. Wer API-Requests verarbeitet, möchte keine rohen Array-Zugriffe auf $request->request->get('name') schreiben und jeden Wert manuell validieren. Mit dem Serializer mappt man den Request-Body direkt auf ein typsicheres DTO, validiert es mit den Symfony-Constraints und hat sofort ein strukturiertes, typisiertes PHP-Objekt für die weitere Verarbeitung. Das ist robuster, schneller zu schreiben und leichter zu testen.

2. Architektur: Normalizer, Encoder und Kontext

Der Symfony Serializer ist kein monolithisches Objekt, sondern ein Stack aus Normalizern und Encodern. Die Normalisierung transformiert ein PHP-Objekt in ein Array-Struktur (und umgekehrt bei der Denormalisierung). Die Encodierung konvertiert das Array in einen String des Zielformats – JSON, XML, CSV oder einen benutzerdefinierten Format. Der ObjectNormalizer ist der Standard-Normalizer für beliebige PHP-Objekte: er nutzt Property-Reflection oder Getter/Setter-Methoden, um Felder zu extrahieren. Der PropertyNormalizer greift direkt auf Properties zu, der GetSetMethodNormalizer exklusiv auf Getter und Setter.

Der Kontext ist ein zentrales Konzept im Symfony Serializer: Ein Array von Schlüssel-Wert-Paaren, das den Normalisierungsprozess steuert. AbstractObjectNormalizer::GROUPS aktiviert Serialization Groups. AbstractObjectNormalizer::SKIP_NULL_VALUES schließt null-Felder aus der Ausgabe aus. AbstractNormalizer::OBJECT_TO_POPULATE gibt ein vorhandenes Objekt an, das bei der Denormalisierung befüllt werden soll, statt ein neues zu erstellen. AbstractObjectNormalizer::ENABLE_MAX_DEPTH begrenzt die Tiefe verschachtelter Objekte. Diese Kontext-Parameter steuern das Verhalten des Serializers ohne Code-Änderungen.


<?php

declare(strict_types=1);

namespace App\Controller\Api;

use App\Dto\CreateProductRequest;
use App\Entity\Product;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route;
use Symfony\Component\Serializer\Context\Normalizer\ObjectNormalizerContextBuilder;
use Symfony\Component\Serializer\SerializerInterface;

/**
 * Controller demonstrating Symfony Serializer normalization and denormalization.
 */
final class ProductController extends AbstractController
{
    public function __construct(
        private readonly SerializerInterface $serializer,
    ) {}

    #[Route('/api/products/{id}', methods: ['GET'])]
    public function show(Product $product): JsonResponse
    {
        // Build normalization context with Serialization Groups
        $context = (new ObjectNormalizerContextBuilder())
            ->withGroups(['product:read'])
            ->withSkipNullValues(true)
            ->toArray();

        // Normalize entity to JSON — only fields in 'product:read' group
        $json = $this->serializer->serialize($product, 'json', $context);

        return new JsonResponse($json, Response::HTTP_OK, [], json: true);
    }

    #[Route('/api/products', methods: ['POST'])]
    public function create(Request $request): JsonResponse
    {
        // Denormalize JSON request body into a typed DTO
        $dto = $this->serializer->deserialize(
            $request->getContent(),
            CreateProductRequest::class,
            'json',
        );

        // $dto is now a fully typed PHP object — no manual array access
        return new JsonResponse(['id' => 'created'], Response::HTTP_CREATED);
    }
}

3. Serialization Groups für differenzierte Ausgabe

Serialization Groups sind das wirkungsvollste Feature des Symfony Serializers für API-Entwicklung. Jedes Feld einer Klasse erhält eine oder mehrere Gruppen über das #[Groups]-Attribut. Im Serialisierungskontext aktiviert man die gewünschten Gruppen, und nur Felder dieser Gruppen werden serialisiert. Das erlaubt es, mit einer einzigen Entity-Klasse verschiedene API-Antworten zu erzeugen: eine kompakte Listenansicht mit ID und Namen, eine vollständige Detailansicht mit allen Feldern und eine Admin-Ansicht mit internen Feldern.

Die Gruppendefinition folgt einer Namenskonvention, die das Lesen des Codes erleichtert: {entity}:list für die Collection-Ansicht, {entity}:read für die Detailansicht, {entity}:write für eingehende Daten und {entity}:admin für privilegierte Ausgaben. Verschachtelte Objekte erhalten eigene Gruppen, um zu steuern, wie tief die Serialisierung in die Objekthierarchie geht. Ohne Gruppen würde der Symfony Serializer rekursiv alle Relationen serialisieren – mit dem Ergebnis, dass eine einfache Produktabfrage die gesamte Kategoriebaum-Struktur in die Antwort einschließt.

4. DTOs mit Constructor Promotion denormalisieren

Das DTO-Pattern in Verbindung mit dem Symfony Serializer ist eine der saubersten Möglichkeiten, eingehende API-Daten zu verarbeiten. Ein DTO (Data Transfer Object) ist eine einfache PHP-Klasse, deren einziger Zweck das Tragen von Daten ist. Mit PHP 8.4 Constructor Property Promotion lässt sich ein vollständiges DTO in wenigen Zeilen definieren: readonly-Properties, typisiert, unveränderlich nach der Erstellung. Der Symfony Serializer kann solche readonly-Klassen über den Konstruktor befüllen – er erkennt, welche Konstruktorparameter welchen JSON-Feldern entsprechen, und ruft den Konstruktor mit den deserialisierten Werten auf.

Der Vorteil gegenüber direktem Entity-Mapping: Das DTO ist entkoppelt von der Datenbankstruktur. Felder im DTO können anders heißen als in der Datenbank, können Berechnungen oder Transformationen enthalten und können Validierungsconstraints tragen, die auf den API-Kontext zugeschnitten sind. Die Entity bleibt sauber für ihre Persistenz-Verantwortung, während das DTO die API-Eingabe-Kontrakt abbildet. Wenn sich die API-Schnittstelle ändert, ändert sich nur das DTO – nicht die Entity.


<?php

declare(strict_types=1);

namespace App\Dto;

use Symfony\Component\Serializer\Attribute\Groups;
use Symfony\Component\Serializer\Attribute\SerializedName;
use Symfony\Component\Validator\Constraints as Assert;

/**
 * DTO for creating a new product via the REST API.
 * Immutable after construction — Symfony Serializer populates via constructor.
 */
final readonly class CreateProductRequest
{
    public function __construct(
        #[Groups(['product:write'])]
        #[Assert\NotBlank(message: 'Product name is required.')]
        #[Assert\Length(min: 2, max: 255)]
        public string $name,

        #[Groups(['product:write'])]
        #[Assert\NotNull]
        #[Assert\Positive(message: 'Price must be a positive number.')]
        public float $price,

        // Map snake_case JSON field to camelCase PHP property
        #[SerializedName('category_id')]
        #[Groups(['product:write'])]
        #[Assert\Positive]
        public ?int $categoryId = null,

        #[Groups(['product:write'])]
        #[Assert\Length(max: 2000)]
        public ?string $description = null,
    ) {}
}

// Normalization output DTO — different fields for read vs. write context
final readonly class ProductResponse
{
    public function __construct(
        #[Groups(['product:list', 'product:read'])]
        public int $id,

        #[Groups(['product:list', 'product:read'])]
        public string $name,

        // Price only in detail view, not in list
        #[Groups(['product:read'])]
        public string $price,

        #[Groups(['product:read'])]
        public ?string $description,

        // Category is a nested object — serialized with its own groups
        #[Groups(['product:read'])]
        public ?CategoryResponse $category,
    ) {}
}

5. Custom Normalizer für komplexe Typen

Der Symfony Serializer liefert eingebaute Normalizer für die meisten PHP-Typen, aber für domänenspezifische Typen – Money-Objekte, Value Objects, spezifische Datumformate – braucht man eigene Normalizer-Klassen. Ein Custom Normalizer implementiert NormalizerInterface und optional DenormalizerInterface. Die Methode supportsNormalization() entscheidet, für welche Typen dieser Normalizer zuständig ist. Die Methode normalize() konvertiert das Objekt in ein skalares Datum oder Array.

Ein klassisches Beispiel: ein Money-Value-Object, das Betrag und Währung kapselt. Ohne Custom Normalizer würde der Symfony Serializer es als Objekt mit amount- und currency-Properties serialisieren. Mit einem Custom Normalizer kann man es als "price": "29.99 EUR" oder als "price": {"amount": 2999, "currency": "EUR", "formatted": "29,99 €"} ausgeben – je nach API-Konvention. Der Denormalizer wandelt den JSON-Wert zurück in das Money-Objekt, sodass DTOs und Entities direkt typisierte Money-Properties haben können.


<?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;

/**
 * Custom normalizer for the Money value object.
 * Serializes as {"amount": 2999, "currency": "EUR"} and deserializes back.
 */
final class MoneyNormalizer implements NormalizerInterface, DenormalizerInterface
{
    /**
     * Convert Money object to a structured array representation.
     */
    public function normalize(mixed $object, ?string $format = null, array $context = []): array
    {
        /** @var Money $money */
        $money = $object;

        return [
            'amount'    => $money->getAmountInCents(),
            'currency'  => $money->getCurrency(),
            'formatted' => $money->format(),  // e.g., "29,99 €"
        ];
    }

    public function supportsNormalization(mixed $data, ?string $format = null, array $context = []): bool
    {
        return $data instanceof Money;
    }

    /**
     * Convert array back to a Money value object.
     */
    public function denormalize(mixed $data, string $type, ?string $format = null, array $context = []): Money
    {
        if (is_array($data)) {
            return Money::fromCents((int) $data['amount'], (string) $data['currency']);
        }

        // Accept simple float values as EUR amounts for backward compatibility
        return Money::fromFloat((float) $data, 'EUR');
    }

    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];
    }
}

6. Typsichere Deserialisierung mit PHP 8.4

PHP 8.4 bringt mit Property Hooks und verbesserter Readonly-Semantik neue Möglichkeiten für typsichere DTOs, die der Symfony Serializer vollständig unterstützt. Enum-Typen in DTOs werden automatisch deserialisiert: ein JSON-Wert "status": "published" wird in das entsprechende Enum-Case ArticleStatus::PUBLISHED konvertiert, wenn die DTO-Property den Enum-Typ hat. Für Backed Enums (string- oder int-backed) funktioniert das ohne Custom Normalizer – der eingebaute BackedEnumNormalizer übernimmt die Konvertierung.

Union-Typen in DTOs brauchen jedoch Custom Normalizer oder Discriminator-Konfiguration, um zu bestimmen, welche Klasse bei der Deserialisierung instanziiert werden soll. Der Symfony Serializer bietet den DiscriminatorMap-Mechanismus: Ein Feld im JSON identifiziert den konkreten Typ, und der Serializer wählt die entsprechende PHP-Klasse. Das ist das klassische Polymorphie-Muster für API-Design, bei dem ein Endpunkt verschiedene Subtypen verarbeiten kann ohne Typ-Prüfungen im Controller-Code.

7. Verschachtelte Objekte und Relationen

Verschachtelte Objekte sind im Symfony Serializer durch die Gruppen-Konfiguration kontrolliert. Eine Product-Entity mit einer Category-Relation serialisiert die Kategorie nur dann, wenn beide Klassen entsprechende #[Groups]-Annotierungen haben und die Gruppe im Kontext aktiv ist. Ohne Gruppen würde das Serializer rekursiv die gesamte Objekthierarchie serialisieren – was bei Doctrine-Entities mit bidirektionalen Relationen zu zirkulären Referenzen und einem MaxItemCountException führt.

Die #[MaxDepth(n)]-Annotation begrenzt die Tiefe verschachtelter Serialisierung: Bei einer Tiefe von 2 werden Objekte auf Ebene 3 nicht mehr vollständig serialisiert, sondern nur ihr ID-Wert oder ein konfigurierbares Circular-Reference-Handler-Ergebnis wird verwendet. Für komplexe Graphen ist es oft besser, statt Doctrine-Entities direkte Response-DTOs zu verwenden: Der Controller lädt die Entity, mappt sie explizit in ein Response-DTO und serialisiert nur das DTO. Das gibt vollständige Kontrolle über die Ausgabestruktur, ohne sich auf die Gruppen-Konfiguration verlassen zu müssen.

8. Deserialisierung und Validierung kombinieren

Die natürliche Erweiterung der Symfony Serializer-Deserialisierung ist die sofortige Validierung des deserialisierten DTOs. Das Muster ist direkt: Request-Body deserialisieren, Validator-Service aufrufen, Violations in eine strukturierte Fehlerantwort umwandeln. Symfony 7 macht das mit dem #[MapRequestPayload]-Attribut noch kompakter: das Attribut an einem Controller-Parameter übernimmt Deserialisierung, Validierung und Fehlerbehandlung automatisch. Der Controller erhält direkt das validierte DTO – oder Symfony sendet eine 422-Antwort mit Validierungsfehlern, wenn das DTO nicht valide ist.

Für APIs, die mehrere Formate unterstützen – JSON, XML, Form-Data – ist der Symfony Serializer die einheitliche Lösung. Das Content-Type-Header der Anfrage bestimmt, welcher Decoder verwendet wird. Der Controller-Code ist identisch, unabhängig vom Eingabeformat. Das ist besonders nützlich für Legacy-Integrationen, die XML senden, während neue Clients JSON verwenden – derselbe Controller, dieselbe Validierung, dasselbe DTO.

Strategie Vorgehen Geeignet für Nachteil
Entity direkt Entity mit Groups serialisieren Kleine APIs, schnelle Entwicklung Datenbankstruktur in API sichtbar
Response DTO Entity → DTO → Serializer Stabile APIs, klarer Vertrag Mehr Mapping-Code nötig
Custom Normalizer Eigene Normalizer-Klasse Value Objects, komplexe Typen Mehr Klassen im Projekt
#[MapRequestPayload] Automatische Deserialisierung + Validierung Symfony 7 Controller, einfache APIs Wenig Kontrolle über Fehlerformat

9. Serializer-Strategien im Vergleich

Die beste Strategie hängt von den Anforderungen des Projekts ab. Für kleine interne APIs, die sich selten ändern, ist das direkte Entity-Mapping mit Gruppen ausreichend. Für öffentliche APIs mit Stabilität-Garantien ist das Response-DTO-Muster die bessere Wahl: Die API-Struktur ist explizit in einer DTO-Klasse definiert und ändert sich nicht, wenn die Datenbankstruktur refaktoriert wird. Für Value-Object-reiche Domänen kommen Custom Normalizer hinzu, die das Symfony Serializer-Ökosystem um domänenspezifische Typen erweitern.

In Projekten mit API Platform 4 ist der Symfony Serializer bereits vollständig integriert: API Platform nutzt denselben Serializer, dieselben Groups und dieselben Custom Normalizer. Eine Custom-Normalizer-Klasse, die für einen Controller-Endpunkt geschrieben wurde, funktioniert auch automatisch für API-Platform-Ressourcen. Das macht den Symfony Serializer zu einer projektweiten Infrastrukturkomponente, nicht zu einem pro-Controller duplizierten Werkzeug.

Mironsoft

Symfony API-Entwicklung, Serializer-Architektur und DTO-Design

Symfony Serializer und DTO-Pipeline aufbauen?

Wir entwerfen und implementieren durchdachte Serializer-Strategien für Symfony-APIs – von Serialization-Group-Architekturen über Custom Normalizer für Value Objects bis zur vollständigen DTO-Pipeline mit Validierung.

DTO-Design

Request- und Response-DTO-Architektur mit Constructor Promotion und Serialization Groups

Custom Normalizer

Normalizer für Value Objects, Money-Typen, Enums und domänenspezifische Datenstrukturen

API-Integration

Serializer-Strategie konsistent über Controller-Endpunkte und API-Platform-Ressourcen hinweg

10. Zusammenfassung

Der Symfony Serializer ist eine mächtige Infrastrukturkomponente, die über simples JSON-Encoding weit hinausgeht. Serialization Groups steuern kontextsensitiv, welche Felder serialisiert werden. Custom Normalizer erweitern das System um domänenspezifische Typen. DTOs mit PHP 8.4 Constructor Promotion empfangen eingehende API-Daten typsicher. #[MapRequestPayload] kombiniert Deserialisierung und Validierung in einer Controller-Annotation. Alle diese Features greifen ineinander und schaffen eine konsistente Datenschicht, die sowohl für ausgehende API-Antworten als auch für eingehende Requests funktioniert.

Der wichtigste Grundsatz: Entities nicht direkt serialisieren, wenn die API eine stabile, datenbankstrukturunabhängige Schnittstelle bieten soll. Das Response-DTO-Muster – Entity in explizites DTO mappen, DTO serialisieren – gibt die Kontrolle zurück und entkoppelt API-Vertrag von Persistenz-Implementierung. Das ist die Basis für wartbare, testbare und langlebige Symfony-APIs, die sich unabhängig von Datenbankmigrationen weiterentwickeln können.

Symfony Serializer — Das Wichtigste auf einen Blick

Serialization Groups

#[Groups] auf Properties + Kontext beim Serialisieren – differenzierte Ausgabe für List, Detail und Admin ohne mehrere Klassen.

DTO-Deserialisierung

Readonly-DTOs mit Constructor Promotion – der Serializer befüllt den Konstruktor mit deserialisierten Werten. Typsicher ohne manuelle Array-Zugriffe.

Custom Normalizer

NormalizerInterface + DenormalizerInterface für Value Objects, Money-Typen und domänenspezifische Datenstrukturen implementieren.

#[MapRequestPayload]

Symfony 7-Attribut für automatische Deserialisierung + Validierung in Controller-Parametern – kein Boilerplate im Controller-Body.

11. FAQ: Symfony Serializer und DTO-Mapping

1Was ist der Symfony Serializer?
Komponente für bidirektionale Konvertierung: PHP-Objekte zu JSON/XML (Normalisierung) und JSON/XML zu PHP-Objekten (Denormalisierung) – mit Groups, Custom Normalizern und DTO-Support.
2Was sind Serialization Groups?
#[Groups] markiert Properties – im Kontext aktivierte Gruppen bestimmen, welche Felder serialisiert werden. Verschiedene Ausgaben aus einer Klasse ohne Duplikation.
3Readonly-DTOs deserialisieren?
ObjectNormalizer erkennt readonly-Properties und befüllt den Konstruktor. JSON-Felder werden auf Konstruktorparameter gemappt. Kein Setter nötig.
4Wann Custom Normalizer?
Für Value Objects, Money-Klassen, Enums mit speziellem Format oder externe Klassen ohne Attribut-Support. NormalizerInterface + supportsNormalization() implementieren.
5Was macht #[MapRequestPayload]?
Symfony 7: automatische Deserialisierung + Validierung in einem Controller-Attribut. Bei Fehlern sendet Symfony 422. Controller empfängt nur das validierte DTO.
6Zirkuläre Referenzen verhindern?
Serialization Groups: Gegenseite ohne Gruppe oder mit begrenzter Gruppe. Alternativ #[MaxDepth] oder Response-DTOs statt Entities serialisieren.
7Doctrine-Entities direkt serialisieren?
Möglich mit Groups, aber Vorsicht vor N+1-Abfragen. Für stabile APIs ist Response-DTO-Pattern die sicherere Alternative – API-Struktur unabhängig von Datenbankstruktur.
8PHP-Enums serialisieren?
Backed Enums automatisch via BackedEnumNormalizer – Value wird ausgegeben, beim Deserialisieren zurück in Enum-Case konvertiert. Kein Custom Normalizer nötig.
9normalize() vs. serialize()?
normalize() → PHP-Array. serialize() → String (JSON/XML). serialize() ruft intern normalize() + encode() auf. normalize() für Debugging der Zwischenstruktur nützlich.
10Symfony Serializer mit API Platform?
Ja – API Platform nutzt intern denselben Serializer. Custom Normalizer und Groups gelten automatisch auch für API-Platform-Ressourcen. Eine einheitliche Konfiguration, projektübergreifend.