Controller, DTOs, Serializer, Validator und Error Handling
Eine produktionsreife Symfony REST API entsteht nicht durch das Zusammenstecken einzelner Komponenten – sie erfordert eine klare Architektur, die alle Teile konsistent verbindet: Request-Deserialisierung, Validierung, Business-Logik, Response-Serialisierung und einheitliches Error Handling. Dieser Guide zeigt den vollständigen Aufbau mit Symfony 7 und PHP 8.4.
Inhaltsverzeichnis
- 1. Die Architektur einer produktionsreifen Symfony REST API
- 2. Projekt-Setup: Pakete und Konfiguration
- 3. Controller: dünn, koordinierend, typsicher
- 4. ArgumentValueResolver: automatische Deserialisierung und Validierung
- 5. Application-Service: Business-Logik ohne HTTP-Abhängigkeiten
- 6. Zentrales Error Handling: Exception-Listener und Problem Details
- 7. Architekturansätze im Vergleich
- 8. Zusammenfassung
- 9. FAQ
1. Die Architektur einer produktionsreifen Symfony REST API
Eine produktionsreife Symfony REST API besteht aus mehreren klar voneinander getrennten Schichten. Der Controller ist der Eingangspunkt für HTTP-Requests. Er koordiniert, aber enthält keine Business-Logik. Er empfängt typisierte Eingabe-DTOs und gibt typisierte Response-DTOs zurück. Der Application-Service enthält die Anwendungslogik – er orchestriert Domain-Objekte, Repositories und externe Services. Er hat keine Kenntnis von HTTP, JSON oder Symfony Requests. Die Domain-Schicht enthält Entities, Value Objects und Domain-Services mit reinen Geschäftsregeln. Die Infrastruktur-Schicht enthält Doctrine-Repositories, externe API-Clients und Queue-Implementierungen.
Zwischen Controller und Application-Service stehen die DTOs als typsichere Datencontainer. Der ArgumentValueResolver kümmert sich um die Deserialisierung des Request-Bodys in ein Request-DTO und die Validierung. Der Exception-Listener übersetzt alle nicht behandelten Exceptions in strukturierte Problem-Details-Antworten. Das Ergebnis ist eine API, bei der jede Schicht klare Verantwortlichkeiten hat, kein HTTP-Code in die Domain durchdringt, und Fehler einheitlich behandelt werden – egal aus welcher Schicht sie kommen.
2. Projekt-Setup: Pakete und Konfiguration
Eine Symfony REST API braucht eine überschaubare Menge an Packages. Das Symfony Skeleton ist der richtige Startpunkt – nicht das Website-Skeleton, das unnötige Frontend-Abhängigkeiten mitbringt. Die zentralen Pakete: symfony/serializer für Deserialisierung und Serialisierung, symfony/validator für Eingabevalidierung, doctrine/doctrine-bundle und doctrine/orm für Datenbankzugriff, lexik/jwt-authentication-bundle für JWT-Authentifizierung, und nelmio/api-doc-bundle für OpenAPI-Dokumentation. Für Entwicklung kommen symfony/maker-bundle und phpstan/phpstan dazu.
Die Symfony Serializer-Konfiguration muss explizit für readonly Properties und Constructor Property Promotion konfiguriert werden. Der serializer.mapping-Pfad und die property_info-Komponente müssen aktiv sein. Für die Deserialisierung in DTOs benötigt man den PropertyNormalizer oder ObjectNormalizer korrekt konfiguriert. Die Fehlerkonfiguration in config/packages/framework.yaml muss error_controller: false für API-Endpunkte setzen, damit keine HTML-Fehlerseiten zurückgegeben werden – der eigene Exception-Listener übernimmt die Kontrolle.
# Project setup — Symfony REST API from scratch
composer create-project symfony/skeleton mironsoft-api
cd mironsoft-api
# Core API packages
composer require symfony/serializer \
symfony/validator \
symfony/property-info \
doctrine/doctrine-bundle \
doctrine/orm \
symfony/uid
# Authentication
composer require lexik/jwt-authentication-bundle
composer require symfony/rate-limiter
# API Documentation
composer require nelmio/api-doc-bundle
# Dev tools
composer require --dev symfony/maker-bundle \
phpstan/phpstan \
phpstan/extension-installer \
phpunit/phpunit \
symfony/phpunit-bridge
# Generate JWT keys
php bin/console lexik:jwt:generate-keypair
3. Controller: dünn, koordinierend, typsicher
Der Symfony-Controller in einer REST API hat eine einzige Verantwortung: Koordination. Er nimmt das bereits deserialisierte und validierte Request-DTO entgegen, ruft den Application-Service auf, nimmt das Ergebnis, mappt es auf ein Response-DTO und serialisiert es. Keine Datenbankabfragen, keine Validierungslogik, keine Business-Regeln. Ein Controller der diese Prinzipien einhält, hat in der Regel 10–20 Zeilen Code pro Action-Methode und ist trivial testbar – man instanziiert den Service mit einem Mock und überprüft das Ergebnis.
In Symfony 7 mit PHP 8.4 sind Controller-Attribute der Standard. Das #[Route]-Attribut direkt auf der Klasse definiert den URL-Prefix, auf der Methode die spezifische Route, HTTP-Methode und Name. Controller sollen final sein – Vererbung von Controllern ist ein Anti-Pattern. Das Autowiring via Constructor Property Promotion funktioniert für alle benötigten Services. Die Response-Serialisierung erfolgt via $this->serializer->serialize($dto, 'json') mit dem entsprechenden Content-Type-Header.
4. ArgumentValueResolver: automatische Deserialisierung und Validierung
Der ArgumentValueResolver ist das Herzstück der automatischen Request-DTO-Verarbeitung. Er greift, wenn ein Controller-Argument einen Typ hat, der von einer Marker-Interface oder aus einem definierten Namespace stammt. Der Resolver holt den JSON-Body aus dem Request, deserialisiert ihn in das gewünschte DTO, validiert es mit dem Symfony Validator und wirft eine ValidationException mit der vollständigen ConstraintViolationList, wenn Fehler vorhanden sind. Der Controller sieht davon nichts – er empfängt das fertige, valide DTO.
Der Exception-Listener fängt die ValidationException ab und baut daraus eine strukturierte Problem-Details-Antwort mit dem errors-Array. Jede Violation wird dabei in ein Feldfehler-Objekt umgewandelt: Feldpfad aus $violation->getPropertyPath(), Fehlermeldung aus $violation->getMessage(), abgelehnter Wert aus $violation->getInvalidValue(). Das Ergebnis ist ein konsistentes Muster: Request rein, Validierungsfehler als Problem Details raus, oder valides DTO in den Controller.
<?php
// src/ArgumentResolver/RequestDtoResolver.php
// Automatic Request-DTO deserialization and validation
declare(strict_types=1);
namespace App\ArgumentResolver;
use App\Dto\Request\RequestDtoInterface;
use App\Exception\ValidationException;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpKernel\Controller\ValueResolverInterface;
use Symfony\Component\HttpKernel\ControllerMetadata\ArgumentMetadata;
use Symfony\Component\Serializer\Exception\NotNormalizableValueException;
use Symfony\Component\Serializer\SerializerInterface;
use Symfony\Component\Validator\Validator\ValidatorInterface;
final class RequestDtoResolver implements ValueResolverInterface
{
public function __construct(
private readonly SerializerInterface $serializer,
private readonly ValidatorInterface $validator,
) {}
public function resolve(Request $request, ArgumentMetadata $argument): iterable
{
$type = $argument->getType();
if (!is_string($type) || !is_a($type, RequestDtoInterface::class, true)) {
return [];
}
$content = $request->getContent();
if (empty($content)) {
return [new $type()]; // Use defaults for empty body
}
try {
$dto = $this->serializer->deserialize(
$content,
$type,
'json',
['allow_extra_attributes' => false]
);
} catch (NotNormalizableValueException $e) {
throw new ValidationException(
"Request body cannot be deserialized: {$e->getMessage()}"
);
}
$violations = $this->validator->validate($dto);
if (count($violations) > 0) {
throw ValidationException::fromViolationList($violations);
}
return [$dto];
}
}
5. Application-Service: Business-Logik ohne HTTP-Abhängigkeiten
Der Application-Service ist die Schicht zwischen Controller und Domain. Er empfängt Request-DTOs, orchestriert Domain-Objekte und Repositories, und gibt Domain-Ergebnisse zurück die der Controller in Response-DTOs mappt. Der Application-Service hat keine Abhängigkeiten auf Symfony's HttpFoundation-Komponente – kein Request-Objekt, keine Response-Objekte, keine Session. Das macht ihn unabhängig vom HTTP-Layer und direkt aus Command-Line-Befehlen, Message-Queue-Handlern oder Tests nutzbar.
In PHP 8.4 nutzt man Constructor Property Promotion für alle Abhängigkeiten des Application-Service. Transaktionsgrenzen werden im Service definiert – entweder via Doctrine's $this->entityManager->wrapInTransaction() oder via #[Transactional]-Attribute mit dem Doctrine Extensions Bundle. Domain-Exceptions werden nicht im Service gefangen – sie propagieren nach oben zum Exception-Listener. Nur erwartete technische Fehler (z.B. externe API timeout) werden gefangen und in Domain-Exceptions umgewandelt. Das hält die Application-Service-Methoden kompakt und die Fehlerbehandlung zentral.
<?php
// src/Service/ProductService.php
// Application Service — no HTTP dependencies, orchestrates domain
declare(strict_types=1);
namespace App\Service;
use App\Dto\Request\CreateProductRequest;
use App\Entity\Product;
use App\Exception\DuplicateSkuException;
use App\Repository\ProductRepository;
use Doctrine\ORM\EntityManagerInterface;
final class ProductService
{
public function __construct(
private readonly ProductRepository $productRepository,
private readonly EntityManagerInterface $entityManager,
) {}
/**
* Create a new product from a validated request DTO.
*
* @throws DuplicateSkuException If a product with the same SKU already exists.
*/
public function create(CreateProductRequest $request): Product
{
// Domain invariant check via repository — not in controller
if ($this->productRepository->existsBySku($request->sku)) {
throw new DuplicateSkuException($request->sku);
}
$product = Product::create(
name: $request->name,
sku: $request->sku,
price: $request->price,
currency: $request->currency,
type: ProductType::from($request->type),
description: $request->description,
);
foreach ($request->tags as $tag) {
$product->addTag($tag);
}
$this->entityManager->persist($product);
$this->entityManager->flush();
return $product;
}
/**
* Find a product by ID or throw a not-found exception.
*
* @throws ProductNotFoundException If no product with the given ID exists.
*/
public function findOrFail(string $id): Product
{
$product = $this->productRepository->find($id);
if ($product === null) {
throw ProductNotFoundException::forId($id);
}
return $product;
}
}
6. Zentrales Error Handling: Exception-Listener und Problem Details
Zentrales Error Handling bedeutet, dass keine einzelne Controller-Methode try/catch-Blöcke braucht – alle Exceptions propagieren nach oben zum kernel.exception-EventSubscriber. Dieser prüft den Request-Accept-Header, identifiziert den Exception-Typ und übersetzt ihn in eine RFC-9457-konforme Problem-Details-Antwort. Domain-Exceptions, Validierungsfehler, 404-Fehler und unerwartete System-Fehler werden alle im selben Listener behandelt – konsistent, mit Correlation ID, mit dem richtigen HTTP-Statuscode.
Die Exception-Hierarchie ist wichtig: Alle Domain-Exceptions erben von App\Exception\DomainException, die einen ApiErrorCode als Property trägt. Validierungsfehler werden via ValidationException mit einer ConstraintViolationList geworfen. Symfony-eigene Exceptions (AccessDeniedException, NotFoundHttpException) werden ebenfalls im Listener abgefangen und auf Problem Details gemappt. Nur für System-Exceptions (\Throwable catch-all) wird ein generischer HTTP-500-Response ohne interne Details zurückgegeben. Der Stack Trace und die ursprüngliche Exception-Message erscheinen nur im Log, nie in der API-Antwort.
| Exception-Typ | HTTP-Status | Problem-Details-Code | Interne Details exponiert |
|---|---|---|---|
| ValidationException | 422 | VALIDATION_FAILED + errors-Array | Nein |
| DomainException | 422 / 409 / 404 | ApiErrorCode-Wert | Nein |
| AccessDeniedException | 403 | ACCESS_DENIED | Nein |
| NotFoundHttpException | 404 | RESOURCE_NOT_FOUND | Nein |
| \Throwable (alle anderen) | 500 | INTERNAL_ERROR | Nie – nur Correlation ID |
8. Zusammenfassung
Eine produktionsreife Symfony REST API mit PHP 8.4 und Symfony 7 entsteht aus klaren Schichten und klaren Verantwortlichkeiten. Controller koordinieren ohne Business-Logik. Request-DTOs als typsichere Eingabeobjekte werden automatisch via ArgumentValueResolver deserialisiert und validiert. Application-Services orchestrieren Domain-Objekte ohne HTTP-Abhängigkeiten. Response-DTOs definieren explizit die API-Ausgabe ohne Datenleckage durch Entity-Serialisierung. Der zentrale Exception-Listener übersetzt alle Exceptions in RFC-9457-konforme Problem-Details-Antworten mit Correlation IDs.
Das Muster ist nicht neu – es setzt bekannte Clean-Architecture-Prinzipien in einer konkreten Symfony-Implementierung um. Jeder Teil ist einzeln testbar: Controller mit gemocktem Service, Application-Service mit gemocktem Repository, Exception-Listener mit synthetischen Exceptions. PHPStan auf Level 8 findet durch die durchgehende Typisierung Fehler, die ohne DTOs unsichtbar wären. Das Ergebnis ist eine API-Codebasis, die mit wachsenden Anforderungen skaliert ohne zur Wartungsschuld zu werden.
Symfony REST API von Grund auf — Das Wichtigste auf einen Blick
Schichten-Architektur
Controller koordiniert. Application-Service enthält Business-Logik. Domain ist HTTP-frei. Infrastruktur enthält DB und externe Clients. Keine Schicht verletzt die nächste.
ArgumentValueResolver
Automatische Deserialisierung + Validierung vor dem Controller. Controller empfängt nur valide DTOs. ValidationException propagiert zum Error-Listener.
Exception-Listener
Kein try/catch in Controllern. Alle Exceptions landen im kernel.exception-Listener. RFC 9457 Problem Details mit Correlation ID für alle Fehlertypen.
PHP 8.4 Features
readonly DTOs mit Constructor Property Promotion. Typed Properties für PHPStan Level 8. Enums für ApiErrorCodes und Domain-Status-Werte.