{ }
GET
Symfony · PHP 8.4 · DTOs · Serializer · Validator
Request-DTOs und Response-DTOs
statt Arrays überall in Symfony REST APIs

Arrays als Request- und Response-Datencontainer sind das häufigste Wartungsproblem in Symfony REST APIs: keine Typsicherheit, kein Autocompletion, keine Dokumentation aus dem Code. PHP 8.4 readonly-Properties, der Symfony Serializer und der Symfony Validator machen DTOs zu einem klaren Upgrade – mit weniger Code, mehr Sicherheit und besserer IDE-Unterstützung.

16 Min. Lesezeit Request-DTOs · Response-DTOs · Serializer · Validator · readonly PHP 8.4 · Symfony 7 · Constructor Property Promotion

1. Das Array-Problem in Symfony REST APIs

In vielen gewachsenen Symfony-APIs sieht der typische Controller so aus: $data = $request->toArray(), gefolgt von manuellem Zugriff auf $data['name'], $data['price'] ?? null, und einem Haufen isset-Prüfungen. Das Ergebnis ist Code, der weder typsicher noch dokumentiert ist. Die IDE hat keine Ahnung, was im Array steckt. PHPStan findet keine Fehler, weil Arrays alles enthalten dürfen. Das Refactoring eines Feldnamens erfordert eine Suche durch die gesamte Codebasis. Und wenn ein Feld optional ist, muss an jeder Stelle separat geprüft werden, ob es vorhanden ist.

Das gleiche Problem gibt es auf der Ausgabeseite: return $this->json(['id' => $product->getId(), 'name' => $product->getName(), ...]) exponiert direkt die Entity-Struktur, was zu versehentlicher Datenleckage führt, wenn neue Entity-Felder hinzukommen. Es gibt keine zentrale Stelle, die definiert, welche Felder für welchen Endpunkt sichtbar sind. Der Serializer kann keine Typen prüfen. Die OpenAPI-Dokumentation muss manuell gewartet werden, weil sie nicht aus dem Code generiert werden kann. DTOs lösen alle diese Probleme auf einmal.

2. Was DTOs sind und was sie nicht sind

Ein Data Transfer Object (DTO) ist ein Objekt, das ausschließlich dazu dient, Daten zwischen Schichten zu transportieren. Es hat keine Geschäftslogik, keine Datenbankverbindung, keine Services als Dependencies. Es ist eine typsichere Datenstruktur. In PHP 8 mit Constructor Property Promotion und readonly-Properties ist ein DTO eine finale Klasse mit einem einzigen Konstruktor und ausschließlich public readonly Properties – kein Setter, kein Getter, kein Boilerplate.

Ein Request-DTO ist das Eingabe-DTO für einen API-Endpunkt: Es repräsentiert den validierten, typisierten Request-Body oder Query-Parameter-Satz. Ein Response-DTO ist das Ausgabe-DTO: Es definiert exakt, welche Felder in der API-Antwort erscheinen. DTOs sind keine Entities – sie werden nicht persistiert. Sie sind keine Value Objects – sie müssen keine Invarianten schützen. Sie sind keine Commands oder Events – sie haben keine semantische Bedeutung im Domäne-Sinne. Sie sind reine Datencontainer mit Typen. Diese Einfachheit ist ihre Stärke.


<?php
// src/Dto/Request/CreateOrderRequest.php
// Readonly request DTO with full validation — no mutable state

declare(strict_types=1);

namespace App\Dto\Request;

use App\Dto\Request\Nested\OrderItemRequest;
use Symfony\Component\Validator\Constraints as Assert;

/**
 * Request DTO for creating a new order.
 * Validates all input before it reaches the domain layer.
 */
final class CreateOrderRequest
{
    /**
     * @param OrderItemRequest[] $items
     */
    public function __construct(
        #[Assert\NotBlank]
        #[Assert\Uuid(versions: [4])]
        public readonly string $customerId,

        #[Assert\NotBlank]
        #[Assert\Count(min: 1, max: 50)]
        #[Assert\Valid]
        public readonly array $items,

        #[Assert\Valid]
        public readonly ?ShippingAddressRequest $shippingAddress = null,

        #[Assert\Choice(choices: ['standard', 'express', 'overnight'])]
        public readonly string $deliveryMethod = 'standard',

        #[Assert\Length(max: 500)]
        public readonly ?string $note = null,
    ) {}
}

3. Request-DTOs: typsichere Eingabe mit Validierung

Ein Request-DTO kombiniert zwei Aufgaben: Deserialisierung des eingehenden JSON-Bodys in ein typsicheres PHP-Objekt, und Validierung der Eingabe. Beides passiert bevor der Controller-Code läuft. Mit dem Symfony Serializer lässt sich der Request-Body direkt in das DTO deserialisieren. Mit dem Symfony Validator lässt sich das DTO anschließend gegen alle Constraints prüfen. Wenn beides erfolgreich ist, hat der Controller-Code die Garantie, dass das DTO valide Daten enthält – ohne einen einzigen if isset-Check.

PHP 8.4 readonly-Properties erzwingen Immutabilität: Einmal aus dem Request deserialisiert, kann das DTO nicht verändert werden. Das verhindert eine Klasse von Bugs, bei denen Request-Daten auf dem Weg durch die Anwendung unbeabsichtigt mutiert werden. Constructor Property Promotion reduziert den Boilerplate auf null – keine getrennten Property-Deklarationen, keine Setter, keine Body-Zuweisung im Konstruktor. Das DTO ist der Konstruktor. Symfony Validator-Constraints als Attribute direkt auf den Properties sorgen dafür, dass Validierungsregeln und Datenstruktur niemals auseinanderdriften.

4. Response-DTOs: strukturierte Ausgabe ohne Leakage

Response-DTOs definieren exakt, welche Felder in welchem Format in der API-Antwort erscheinen. Das ist der Gegensatz zur häufigen Praxis, Doctrine-Entities direkt zu serialisieren. Wenn man eine Entity serialisiert, erscheinen automatisch alle Felder – auch neue, die nach dem initialen Release hinzugekommen sind. Das ist ein stilles Datenleck: ein Feld das intern zum Tracking dient, erscheint plötzlich in öffentlichen API-Antworten, weil jemand die Entity um ein Feld erweitert hat ohne an die API-Ausgabe zu denken.

Response-DTOs machen die API-Ausgabe zu einer expliziten Entscheidung. Ein neues Entity-Feld erscheint nicht automatisch in der API-Antwort – es muss aktiv zum Response-DTO hinzugefügt werden. Das macht Breaking-Changes sichtbar statt implizit. Für verschiedene Endpunkte kann man verschiedene Response-DTOs verwenden: ProductSummaryResponse für Listenansichten mit wenigen Feldern, ProductDetailResponse für Detailansichten mit allen Feldern. Symfony Serializer Groups lösen dasselbe Problem mit einem anderen Ansatz – DTOs sind expliziter und einfacher zu verstehen.


<?php
// src/Dto/Response/ProductDetailResponse.php
// Explicit response DTO — no field leakage, full IDE support

declare(strict_types=1);

namespace App\Dto\Response;

use DateTimeImmutable;

/**
 * Response DTO for product detail endpoint.
 * Defines exactly which fields are exposed via the API.
 */
final class ProductDetailResponse
{
    public function __construct(
        public readonly string            $id,
        public readonly string            $name,
        public readonly string            $slug,
        public readonly float             $price,
        public readonly string            $currency,
        public readonly string            $type,
        public readonly bool              $inStock,
        public readonly int               $stockQuantity,
        public readonly ?string           $description,
        public readonly array             $tags,
        /** @var ProductImageResponse[] */
        public readonly array             $images,
        public readonly DateTimeImmutable $createdAt,
        public readonly DateTimeImmutable $updatedAt,
    ) {}

    /**
     * Create from domain entity — explicit mapping, no surprise fields.
     */
    public static function fromEntity(Product $product): self
    {
        return new self(
            id:            $product->getId()->toString(),
            name:          $product->getName(),
            slug:          $product->getSlug(),
            price:         $product->getPrice()->getAmount(),
            currency:      $product->getPrice()->getCurrency(),
            type:          $product->getType()->value,
            inStock:       $product->isInStock(),
            stockQuantity: $product->getStockQuantity(),
            description:   $product->getDescription(),
            tags:          $product->getTags(),
            images:        array_map(ProductImageResponse::fromEntity(...), $product->getImages()->toArray()),
            createdAt:     $product->getCreatedAt(),
            updatedAt:     $product->getUpdatedAt(),
        );
    }
}

5. Symfony Serializer: Deserialisierung und Serialisierung

Der Symfony Serializer ist das Herzstück der DTO-Pipeline. Für die Deserialisierung des eingehenden Request-Bodys in ein Request-DTO nutzt man $serializer->deserialize($json, CreateOrderRequest::class, 'json'). Der Serializer verwendet Constructor Property Promotion und respektiert die Typ-Deklarationen – er versucht automatisch, JSON-Werte in die deklarierten PHP-Typen zu konvertieren. Für die Serialisierung des Response-DTOs in JSON nutzt man $serializer->serialize($responseDto, 'json'). Der Serializer folgt dabei den deklarierten Typen und Property-Namen.

Für verschachtelte DTOs muss der Serializer wissen, welche PHP-Klasse ein Array-Property enthält. Das geschieht über die #[ArrayOf]-Annotation oder über Symfony Serializer Mappings. Bei readonly DTOs ist der Serializer-Kontext [AbstractNormalizer::ALLOW_EXTRA_ATTRIBUTES => false] wichtig: Er lehnt JSON-Felder ab, die nicht im DTO definiert sind – damit ist Mass Assignment ausgeschlossen. Der Normalizer PropertyNormalizer oder der ObjectNormalizer muss korrekt konfiguriert sein um mit readonly Properties umzugehen.

6. Der Controller: sauber dank DTOs

Mit Request- und Response-DTOs wird der Controller zu dem, was er sein soll: ein dünner Koordinator. Er nimmt das Request-DTO entgegen (bereits deserialisiert und validiert), ruft den Application-Service auf, erhält das Domain-Ergebnis zurück, mappt es auf das Response-DTO und serialisiert es. Keine Validierungslogik, keine Array-Zugriffe, keine manuellen Null-Checks. Jede Zeile im Controller hat eine klare Verantwortung. Das macht Controller-Code testbar, lesbar und wartbar.

Für die automatische Deserialisierung des Request-Bodys in das DTO als Controller-Argument kann man einen Symfony Argument-Value-Resolver schreiben. Dieser greift, wenn ein Controller-Argument den Typ eines Request-DTOs hat, holt den Request-Body, deserialisiert in das DTO, validiert und wirft eine ValidationException wenn Fehler vorhanden sind. Das Ergebnis: der Controller hat keine Deserializierungs-Logik – er empfängt einfach ein typisiertes, validiertes Objekt als Parameter.


<?php
// src/Controller/Api/ProductController.php
// Clean controller with Request-DTO and Response-DTO — no arrays, no noise

declare(strict_types=1);

namespace App\Controller\Api;

use App\Dto\Request\CreateProductRequest;
use App\Dto\Response\ProductDetailResponse;
use App\Service\ProductService;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route;
use Symfony\Component\Serializer\SerializerInterface;

#[Route('/api/products')]
final class ProductController extends AbstractController
{
    public function __construct(
        private readonly ProductService      $productService,
        private readonly SerializerInterface $serializer,
    ) {}

    /**
     * Create a new product.
     * Request body is deserialized and validated automatically via ArgumentResolver.
     */
    #[Route('', name: 'api_product_create', methods: ['POST'])]
    public function create(CreateProductRequest $request): JsonResponse
    {
        // DTO is already validated — no manual validation here
        $product = $this->productService->create($request);

        // Explicit mapping to Response-DTO — no field leakage
        $response = ProductDetailResponse::fromEntity($product);

        return new JsonResponse(
            $this->serializer->serialize($response, 'json'),
            Response::HTTP_CREATED,
            ['Content-Type' => 'application/json'],
            true // Already JSON — skip double-encoding
        );
    }

    /**
     * Get product details.
     */
    #[Route('/{id}', name: 'api_product_show', methods: ['GET'])]
    public function show(string $id): JsonResponse
    {
        $product  = $this->productService->findOrFail($id);
        $response = ProductDetailResponse::fromEntity($product);

        return new JsonResponse(
            $this->serializer->serialize($response, 'json'),
            Response::HTTP_OK,
            ['Content-Type' => 'application/json'],
            true
        );
    }
}

7. Arrays vs. DTOs im direkten Vergleich

Kriterium Arrays überall Request- und Response-DTOs Gewinner
Typsicherheit Keine – alles ist mixed Vollständig – PHP-Typen DTOs
IDE-Autocompletion Nur mit PHPDoc-Hack Nativ, ohne Annotation DTOs
Validierung Manuell, verteilt Zentral per Attribute DTOs
Datenleckage Hohes Risiko bei Entity-Serialisierung Kein Risiko – explizites Mapping DTOs
Refactoring String-basierte Array-Keys Property-Umbenennung via IDE DTOs
Setup-Aufwand Minimal Einmalig – ArgumentResolver Unentschieden

8. Zusammenfassung

Request-DTOs und Response-DTOs in Symfony REST APIs ersetzen das Array-Chaos durch typsichere, validierte, dokumentierbare Datencontainer. PHP 8.4 mit readonly-Properties und Constructor Property Promotion macht DTOs kompakt – keine Getter, keine Setter, kein Boilerplate. Request-DTOs kombinieren Deserialisierung und Validierung in einem Schritt bevor der Controller-Code läuft. Response-DTOs definieren explizit die API-Ausgabe und verhindern Datenleckage durch implizite Entity-Serialisierung.

Der einmalige Aufwand – ein Argument-Value-Resolver für automatische Deserialisierung und Validierung, die DTO-Klassen selbst – zahlt sich sofort aus: Controller werden minimal, PHPStan findet echte Fehler, IDEs bieten vollständige Autocompletion, und OpenAPI-Dokumentation kann aus den DTO-Properties und -Annotations generiert werden. In einer gewachsenen Symfony-API ist die Migration auf DTOs kein All-or-Nothing – man fängt mit einem Endpunkt an und wandert schrittweise um.

Request- und Response-DTOs in Symfony — Das Wichtigste auf einen Blick

Request-DTOs

readonly final class mit Symfony-Validator-Constraints. Automatische Deserialisierung + Validierung via ArgumentResolver. Kein Boilerplate in Controllern.

Response-DTOs

Explizites Mapping in fromEntity()-Methode. Kein Datenleck durch neue Entity-Felder. Verschiedene DTOs für Listen vs. Detail-Ansichten.

PHP 8.4 Features

readonly Properties für Immutabilität. Constructor Property Promotion für Zero-Boilerplate. Typed Properties für vollständige IDE-Unterstützung und PHPStan.

Symfony Integration

ArgumentValueResolver für automatische Deserialisierung. Symfony Serializer mit ALLOW_EXTRA_ATTRIBUTES => false. Validator mit ConstraintViolationList zu Exception.

9. FAQ: Symfony Request-DTOs und Response-DTOs

1DTO vs. Doctrine Entity: was ist der Unterschied?
Entity hat Datenbankverbindung, Identität und Lebenszyklusmethoden. DTO ist reiner Datencontainer ohne Persistierung, ohne Logik. DTOs transferieren Daten zwischen Schichten.
2Dasselbe DTO für Request und Response?
Technisch möglich, praktisch schlechte Idee. Request-DTOs haben Validierungsconstraints, Response-DTOs berechnete Felder. Separate DTOs machen beide Verantwortlichkeiten explizit.
3Automatische Deserialisierung via ArgumentResolver?
ArgumentValueResolver prüft ob Argument-Typ ein Request-DTO ist. Holt JSON, deserialisiert, validiert, wirft bei Fehlern ValidationException. Controller empfängt fertiges, validiertes DTO.
4Vorteil von readonly Properties für DTOs?
Immutabilität nach Konstruktion. DTO kann nicht auf dem Weg durch die Anwendung verändert werden. Kommuniziert klar dass es ein Datencontainer ist.
5Mass Assignment mit DTOs verhindern?
DTO definiert nur erlaubte Properties. Serializer mit ALLOW_EXTRA_ATTRIBUTES => false lehnt unbekannte Felder ab. Das DTO ist die Allowlist.
6Serializer Groups oder separate DTOs?
Separate DTOs sind expliziter und leichter verständlich. Serializer Groups auf Entities werden schnell unübersichtlich. API-Kontrakte jedes Endpunkts sind klar aus dem Code lesbar.
7Verschachtelte Objekte in Response-DTOs?
Eigene DTOs für verschachtelte Objekte. fromEntity()-Pattern überträgt die Hierarchie. Symfony Serializer serialisiert verschachtelte DTOs automatisch korrekt.
8PHPStan und DTOs?
Ja – mit typed Properties erkennt PHPStan inkompatible Typen und nicht-existente Properties. Level 8-Analyse mit DTOs findet Fehler die mit Arrays unsichtbar wären.
9Bestehende API schrittweise auf DTOs migrieren?
Endpunkt für Endpunkt. ArgumentValueResolver der nur für Dto\Request-Namespace greift. Einen Endpunkt umstellen, testen, weiter. Alte Endpunkte bleiben unverändert.
10DTOs und NelmioApiDocBundle für OpenAPI?
NelmioApiDocBundle liest PHP-Typen aus DTO-Konstruktoren und generiert OpenAPI-Schemas. #[OA\Property] auf Properties für Beispiele und Beschreibungen. DTO wird Single Source of Truth.