Request Mapping in Symfony 7
Wer in Symfony-Controllern noch manuell $request->query->get() und json_decode($request->getContent()) schreibt, verschenkt Typsicherheit und Validierung. MapQueryString und MapRequestPayload mappen Query-Parameter und Request-Body seit Symfony 6.3 direkt in typisierte DTOs — automatisch deserialisiert, validiert und einsatzbereit.
Inhaltsverzeichnis
- 1. Das Problem: Request-Daten manuell extrahieren
- 2. MapQueryString: Query-Parameter direkt in DTOs
- 3. MapRequestPayload: JSON-Body typsicher mappen
- 4. DTOs mit PHP 8.4 Constructor Promotion
- 5. Validierung mit Symfony Constraints
- 6. Fehlerbehandlung und HTTP-Antworten
- 7. Verschachtelte DTOs und Collections
- 8. Unterschiedliche Formate: JSON, Form-Data, XML
- 9. Vergleich: Vorher und nachher
- 10. Zusammenfassung
- 11. FAQ
1. Das Problem: Request-Daten manuell extrahieren
In klassischen Symfony-Controllern liest man Query-Parameter über $request->query->get('page', 1), Formulardaten über $request->request->get('name') und JSON-Body über json_decode($request->getContent(), true). All diese Zugriffe liefern mixed — ohne Typsicherheit, ohne automatische Validierung, ohne strukturierte Fehlerrückmeldung. Ein Controller, der fünf Query-Parameter und einen JSON-Body verarbeitet, enthält schnell zehn Zeilen reinen Extraktions-Boilerplate, bevor die eigentliche Logik beginnt.
Das Problem geht über Boilerplate hinaus. Wenn ein Integer-Parameter als String im Query-String ankommt, muss man explizit casten. Wenn ein Pflichtfeld im JSON-Body fehlt, muss man prüfen und eine strukturierte Fehlerantwort zurückgeben. Wenn die Validierungslogik wächst, landet sie häufig direkt im Controller — statt in dedizierten Constraint-Klassen. MapQueryString und MapRequestPayload lösen genau diesen Schmerz: Sie externalissieren Extraktion, Deserialisierung und Validierung in typisierte PHP-Klassen und hinterlassen im Controller nur noch die Geschäftslogik.
2. MapQueryString: Query-Parameter direkt in DTOs
Das Attribut #[MapQueryString] aus dem Namespace Symfony\Component\HttpKernel\Attribute weist Symfony an, den gesamten Query-String des Requests zu nehmen, ihn als verschachteltes Array zu interpretieren und dieses Array in das annotierte Argument-DTO zu deserialisieren. Der Symfony Serializer übernimmt das Mapping von Query-Parameter-Namen auf DTO-Properties. Eigenschaften, die im Query-String fehlen, erhalten ihren PHP-Standardwert — null bei nullablen Typen, den deklarierten Defaultwert bei anderen. Das Ergebnis ist ein vollständig typisiertes PHP-Objekt, das direkt im Controller verwendet werden kann.
Symfony akzeptiert für #[MapQueryString] sowohl einfache Werte als auch Arrays in Query-Notation: ?tags[]=php&tags[]=symfony wird automatisch in ein Array-Property gemappt. Integer-Werte wie ?page=2 werden in int-Properties konvertiert, Boolean-Strings wie ?active=true in bool. Diese automatische Typkonvertierung funktioniert verlässlich für alle Scalar-Typen. Das Argument kann optional sein — wenn kein Query-String vorhanden ist, übergibt Symfony null und der Controller muss das berücksichtigen. Mit #[MapQueryString] auf einem non-nullable DTO-Typ gibt Symfony bei einem leeren Query-String ein Default-Objekt mit den Property-Defaultwerten zurück.
<?php
declare(strict_types=1);
namespace App\Dto;
use Symfony\Component\Validator\Constraints as Assert;
/**
* DTO for product list query parameters — mapped via MapQueryString.
*/
final class ProductListQuery
{
public function __construct(
// Default page is 1 — ?page=2 sets this to 2 automatically
#[Assert\Positive]
public readonly int $page = 1,
// Items per page — clamped by constraint to prevent abuse
#[Assert\Range(min: 1, max: 100)]
public readonly int $limit = 20,
// Optional search term — null if absent from query string
#[Assert\Length(max: 255)]
public readonly ?string $search = null,
// Sort field — only allow known columns
#[Assert\Choice(choices: ['name', 'price', 'createdAt'])]
public readonly string $sort = 'createdAt',
// Sort direction
#[Assert\Choice(choices: ['asc', 'desc'])]
public readonly string $direction = 'desc',
// Tags array — ?tags[]=php&tags[]=symfony
/** @var string[] */
public readonly array $tags = [],
) {}
}
Im Controller selbst ist das Mapping unsichtbar — das Attribut übernimmt alles automatisch:
<?php
declare(strict_types=1);
namespace App\Controller;
use App\Dto\ProductListQuery;
use App\Repository\ProductRepository;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpKernel\Attribute\MapQueryString;
use Symfony\Component\Routing\Attribute\Route;
final class ProductController extends AbstractController
{
public function __construct(
private readonly ProductRepository $productRepository,
) {}
/**
* List products with filtering, sorting and pagination.
* Query params are automatically mapped and validated via MapQueryString.
*/
#[Route('/api/products', methods: ['GET'])]
public function list(#[MapQueryString] ProductListQuery $query): JsonResponse
{
// $query is already typed, validated, and ready to use — no manual extraction
$products = $this->productRepository->findByQuery($query);
return $this->json([
'data' => $products,
'page' => $query->page,
'limit' => $query->limit,
]);
}
}
3. MapRequestPayload: JSON-Body typsicher mappen
Das Attribut #[MapRequestPayload] funktioniert analog zu #[MapQueryString], arbeitet aber auf dem Request-Body: Symfony liest den Body, detektiert das Content-Type-Format (JSON, Form-Data oder XML), deserialisiert ihn mit dem Symfony Serializer in das annotierte DTO und führt anschließend die Symfony-Validierung durch. Bei einem invaliden Body gibt Symfony automatisch eine 422 Unprocessable Entity-Antwort mit strukturierten Validierungsfehlern zurück — ohne eine einzige Zeile manueller Fehlerbehandlung im Controller.
Ein kritischer Unterschied zu #[MapQueryString]: #[MapRequestPayload] schlägt mit einer BadRequestHttpException fehl, wenn der Body fehlt oder nicht geparst werden kann. Das ist das richtige Verhalten für POST-Endpunkte, bei denen ein Body erwartet wird. Für optionale Bodies — etwa bei PATCH-Endpunkten, die nur übermittelte Felder aktualisieren — gibt es den Parameter validationFailedStatusCode: 0, der die automatische Fehlerantwort deaktiviert und stattdessen eine Validator-ConstraintViolationList in der Methode zugänglich macht. So bleibt MapRequestPayload auch für Partial-Update-Szenarien verwendbar.
4. DTOs mit PHP 8.4 Constructor Promotion
PHP 8.4 Constructor Property Promotion macht DTOs für MapQueryString und MapRequestPayload besonders kompakt. Alle Properties werden direkt im Konstruktor deklariert, typisiert und mit Validierungsconstraints annotiert — in einer einzigen Klasse ohne separaten Getter-Boilerplate. Readonly Properties verhindern versehentliche Mutation nach der Deserialisierung. Das DTO ist damit ein vollständig immutables Value Object, das den Zustand eines validen Requests repräsentiert.
Symfony 7 unterstützt für MapRequestPayload-DTOs auch union types und nullbare Properties. Ein Feld wie public readonly int|string|null $identifier = null wird korrekt deserialisiert, wenn der Serializer den Typ aus dem JSON-Wert ableiten kann. Für komplexere Szenarien — etwa wenn ein Feld entweder ein String-Array oder ein einzelner String sein kann — empfiehlt sich ein eigener Normalizer, der dem Symfony Serializer mitteilt, wie das Mapping erfolgen soll. Für die meisten praktischen API-DTOs reichen jedoch einfache readonly-Properties mit Scalar-Typen vollständig aus.
<?php
declare(strict_types=1);
namespace App\Dto;
use Symfony\Component\Validator\Constraints as Assert;
/**
* DTO for creating a new product — mapped via MapRequestPayload from JSON body.
*/
final class CreateProductInput
{
public function __construct(
// Required string — 422 if missing or blank
#[Assert\NotBlank]
#[Assert\Length(min: 2, max: 255)]
public readonly string $name,
// Positive decimal as string — avoids float precision issues
#[Assert\NotBlank]
#[Assert\Positive]
public readonly string $price,
// Optional description with length limit
#[Assert\Length(max: 5000)]
public readonly ?string $description = null,
// Nested category input — validated recursively via Valid constraint
#[Assert\NotNull]
#[Assert\Valid]
public readonly ?CategoryInput $category = null,
// Array of tag strings — each validated individually
/** @var string[] */
#[Assert\All([new Assert\NotBlank(), new Assert\Length(max: 50)])]
public readonly array $tags = [],
) {}
}
/**
* Nested DTO for category — used inside CreateProductInput.
*/
final class CategoryInput
{
public function __construct(
#[Assert\NotBlank]
#[Assert\Positive]
public readonly int $id,
) {}
}
5. Validierung mit Symfony Constraints
Der größte Vorteil von MapQueryString und MapRequestPayload gegenüber manueller Extraktion liegt in der automatischen Validierung. Symfony führt nach der Deserialisierung automatisch den Validator auf dem gemappten Objekt aus. Alle Constraints auf den DTO-Properties — NotBlank, Length, Range, Choice, Email, Url, Valid für verschachtelte Objekte — werden geprüft, bevor der Controller-Code überhaupt ausgeführt wird. Bei Validierungsfehlern gibt Symfony automatisch eine 422 Unprocessable Entity-Antwort zurück, deren Body eine strukturierte Liste der Verletzungen enthält.
Komplexe, cross-field-Validierungen implementiert man als Klassen-Level-Constraints oder als Custom-Validator-Klassen. Ein Custom-Constraint wie #[Assert\Callback] auf dem DTO bekommt das gesamte Objekt und den ExecutionContextInterface übergeben und kann Validierungsverstöße programmatisch hinzufügen. Das Cascade-Attribut #[Assert\Valid] auf einer Nested-DTO-Property löst die Validierung des verschachtelten Objekts aus — so werden Fehler in CategoryInput exakt so gemeldet wie Fehler in der Wurzel-DTO. Validierungsgruppen über validationGroups: ['Default', 'create'] am Attribut erlauben unterschiedliche Validierungsregeln für Create- und Update-Operationen ohne separate DTO-Klassen.
6. Fehlerbehandlung und HTTP-Antworten
Wenn MapRequestPayload einen Validierungsfehler erkennt, wird standardmäßig eine HttpException mit Status 422 geworfen. In einem API-Kontext mit dem Symfony Serializer und aktiviertem Problem-Details-Format (RFC 7807) liefert Symfony eine strukturierte JSON-Antwort mit dem Feld violations, das jeden Fehler mit Pfad, Nachricht und Code beschreibt. Diese Antwort ist für API-Clients direkt interpretierbar — kein eigenes Exception-Handling im Controller nötig.
Für Fälle, in denen man die Fehlerantwort anpassen möchte — etwa um eigene Fehler-Codes oder ein abweichendes Format zu liefern — registriert man einen eigenen ExceptionListener oder einen KernelExceptionEvent-Subscriber. Der Subscriber fängt HttpException mit Status 422 ab und transformiert die ConstraintViolationList in das gewünschte Format. Wer noch mehr Kontrolle braucht, setzt validationFailedStatusCode: 0 am Attribut — dann wirft Symfony keine Exception, sondern übergibt das Objekt mit seinen Violations direkt. In diesem Modus empfängt man die ConstraintViolationList im Controller als separates Argument und kann selbst entscheiden, wie man reagiert.
<?php
declare(strict_types=1);
namespace App\Controller;
use App\Dto\CreateProductInput;
use App\Service\ProductService;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpKernel\Attribute\MapRequestPayload;
use Symfony\Component\Routing\Attribute\Route;
final class ProductController extends AbstractController
{
public function __construct(
private readonly ProductService $productService,
) {}
/**
* Create a new product from JSON request body.
* MapRequestPayload handles deserialization and validation automatically.
* Returns 422 with violation details on invalid input — no manual try/catch needed.
*/
#[Route('/api/products', methods: ['POST'])]
public function create(
#[MapRequestPayload] CreateProductInput $input,
): JsonResponse {
// At this point, $input is guaranteed valid — constraints have been checked
$product = $this->productService->createProduct($input);
return $this->json($product, Response::HTTP_CREATED);
}
}
7. Verschachtelte DTOs und Collections
Sowohl MapQueryString als auch MapRequestPayload unterstützen verschachtelte Objekte. Bei MapRequestPayload deserialisiert der Symfony Serializer tief verschachtelte JSON-Strukturen automatisch, wenn die Properties korrekt typisiert sind. Ein Property vom Typ CategoryInput wird aus dem entsprechenden JSON-Objekt befüllt, ein Array-Property /** @var LineItemInput[] */ mit dem korrekten PHPDoc-Typ wird als Collection deserialisiert. Symfony 7 wertet PHPDoc-Typen für Array-Elemente über den PropertyInfoExtractor aus — die Annotation /** @var LineItemInput[] */ reicht, um den Typ der Array-Elemente zur Deserialisierungszeit zu bestimmen.
Für MapQueryString funktionieren Arrays über die Standard-Query-String-Notation: ?items[0][quantity]=2&items[0][productId]=5 wird in ein Array von verschachtelten Objekten gemappt. Das ist bei komplexen Query-Strings unhandlich — in der Praxis empfiehlt sich für verschachtelte Daten der JSON-Body über MapRequestPayload. Einfache Arrays wie ?tags[]=php&tags[]=symfony sind dagegen auch mit MapQueryString gut handhabbar und werden häufig für Filter-Listen bei GET-Endpunkten eingesetzt. Der Validator prüft Array-Elemente mit #[Assert\All([...])], das jeden Eintrag mit den enthaltenen Constraints validiert.
8. Unterschiedliche Formate: JSON, Form-Data, XML
MapRequestPayload erkennt das Request-Format automatisch anhand des Content-Type-Headers. Bei application/json nutzt Symfony den JSON-Serializer, bei application/x-www-form-urlencoded oder multipart/form-data die Form-Komponente, bei application/xml den XML-Serializer. Das gleiche DTO funktioniert damit für JSON-APIs und Formular-Endpunkte ohne Änderung. In der Praxis empfiehlt sich für REST-APIs ausschließlich JSON als Eingangsformat — das reduziert Komplexität und macht das Verhalten vorhersehbar.
Wer das Format explizit steuern möchte, setzt den Parameter format: 'json' am #[MapRequestPayload]-Attribut. Das erzwingt JSON-Deserialisierung unabhängig vom Content-Type-Header und verhindert, dass Clients das Format unerwartet steuern können. Für Endpunkte, die sowohl JSON als auch Form-Data akzeptieren sollen — etwa für Legacy-Clients — lässt man den Format-Parameter weg und verlässt sich auf die automatische Erkennung. In diesem Fall muss das DTO sicherstellen, dass alle Properties auch über Form-Data-Namen korrekt befüllt werden — Symfony nutzt dafür Property-Namen als Form-Feldnamen, was in den meisten Fällen passt.
9. Vergleich: Vorher und nachher
Der Unterschied zwischen manuellem Request-Handling und MapQueryString/MapRequestPayload zeigt sich am deutlichsten im direkten Code-Vergleich. Die folgende Tabelle stellt typische Aufgaben gegenüber.
| Aufgabe | Manuell | Mit MapQueryString / MapRequestPayload | Vorteil |
|---|---|---|---|
| Query-Parameter lesen | $request->query->get('page', 1) |
$query->page (typisiert) |
Typsicher, kein Cast nötig |
| JSON-Body deserialisieren | json_decode(..., true)['name'] |
$input->name (DTO-Property) |
Strukturiert, kein Array-Zugriff |
| Validierung | Manuell im Controller oder Service | Automatisch via Constraints | 422-Antwort ohne Controller-Code |
| Fehlerantwort | Manuelles JSON-Error-Building | Automatisch strukturiert | RFC 7807 Problem Details inklusive |
| Verschachtelte Objekte | Manuelles Nested-Array-Handling | Automatische DTO-Deserialisierung | Typisiert bis in die Tiefe |
Die Einsparung ist messbar: Ein typischer CRUD-Controller mit fünf Query-Parametern und einem JSON-Body kommt mit MapQueryString und MapRequestPayload auf etwa 30 % weniger Zeilen im Controller-Code. Wichtiger als die Zeilenzahl ist die qualitative Verbesserung: Alle Request-Struktur-Annahmen sind explizit in DTO-Klassen dokumentiert, und die IDE findet alle Verwendungen eines Feldes über normale PHP-Navigation — kein String-Zugriff auf Arrays mehr.
Mironsoft
Symfony API-Entwicklung, Clean Architecture und PHP 8.4 Best Practices
Symfony-Controller mit MapQueryString und MapRequestPayload modernisieren?
Wir refaktorieren bestehende Symfony-APIs auf typisierte DTOs, eliminieren Request-Boilerplate und führen strukturierte Validierung und Fehlerbehandlung ein — für wartbarere Controller und bessere API-Qualität.
DTO-Design
Typisierte Request-DTOs mit PHP 8.4 Constructor Promotion und Symfony Constraints
Controller-Refactoring
Migration von manueller Request-Extraktion zu MapQueryString und MapRequestPayload
API-Qualität
Strukturierte Fehlerantworten nach RFC 7807 und vollständige OpenAPI-Dokumentation
10. Zusammenfassung
MapQueryString und MapRequestPayload sind seit Symfony 6.3 die empfohlene Methode, um Request-Daten in Controllern zu verarbeiten. Beide Attribute eliminieren manuellen Extraktions-Boilerplate, erzwingen Typsicherheit durch typisierte DTOs und führen automatisch Symfony-Validierung durch — bevor der Controller-Code ausgeführt wird. Das Ergebnis sind schlankere Controller, explizitere API-Contracts und strukturierte Fehlerantworten ohne eigenes Exception-Handling.
Für neue Symfony 7-Projekte sollte kein Controller mehr direkt auf $request->query->get() oder json_decode($request->getContent()) zugreifen. Stattdessen kapselt jede Controller-Methode ihre Eingabe in einem DTO — annotiert mit #[MapQueryString] für GET-Parameter oder #[MapRequestPayload] für Body-Daten. Die DTOs dokumentieren den API-Contract selbst, sind testbar ohne HTTP-Request und bilden eine saubere Grenze zwischen Infrastruktur und Domäne.
MapQueryString und MapRequestPayload — Das Wichtigste auf einen Blick
MapQueryString
Mappt Query-Parameter automatisch in typisierte DTOs. Scalar-Konvertierung, Array-Notation und Symfony-Validierung inklusive — kein $request->query->get() mehr.
MapRequestPayload
Deserialisiert JSON, Form-Data und XML in DTOs. Bei Validierungsfehlern automatisch 422 mit strukturierten Violations — kein manuelles Error-Handling nötig.
DTO-Design
PHP 8.4 Constructor Property Promotion mit readonly Properties. Constraints direkt auf Properties — immutabel, testbar, IDE-navigierbar.
Verschachtelung
Nested DTOs und Collections werden automatisch deserialisiert. #[Assert\Valid] kaskadiert Validierung in verschachtelte Objekte.