{ }
GET
Symfony 7 · PHP 8.4 · REST API · Vollständige Anleitung
Symfony REST API von Grund auf aufbauen
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.

25 Min. Lesezeit Controller · DTOs · Serializer · Validator · Error Handling PHP 8.4 · Symfony 7 · Doctrine ORM

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.

9. FAQ: Symfony REST API von Grund auf aufbauen

1symfony/skeleton vs. website-skeleton für REST APIs?
Immer symfony/skeleton – minimal, ohne Twig und Frontend-Abhängigkeiten. Nur benötigte Pakete hinzufügen. website-skeleton enthält unnötige Web-Komponenten für reine APIs.
2Brauche ich API Platform?
Nein. API Platform ist mächtig für CRUD-APIs, bringt aber erhebliche Komplexität. Für APIs mit komplexer Business-Logik ist eigener Stack mit Controller, DTOs und Application-Service oft wartbarer.
3Wie teste ich Symfony REST API Controller?
Unit-Tests mit gemocktem Service für Koordinationslogik. Funktionale Tests mit Symfony HTTP-Kernel für vollständige Pipeline. WebTestCase oder KernelTestCase für Funktionale Tests.
4API-Versioning in Symfony?
URL-Versioning (/api/v1/) ist am einfachsten. Separate Controller-Namespaces pro Version. Gemeinsame Application-Services wenn möglich, separate DTOs bei Änderungen.
5Pagination in Symfony REST APIs?
Cursor-basiert ist performanter als Offset für große Datensätze. PaginationRequest-DTO mit cursor + limit. PaginatedResponse-DTO mit items, next_cursor, has_more.
6Content Negotiation implementieren?
Symfony hat eingebaute Negotiation via Request::getPreferredFormat(). Für REST APIs reicht meist Accept-Header auf application/json prüfen. Komplexere Negotiation als EventSubscriber.
7Application-Service Entities oder Domain-Objekte zurückgeben?
Pragmatisch: Doctrine Entities als Domain-Objekte mit Rich Domain Model. Reine DDD-Projekte trennen das. Controller mappt Entity auf Response-DTO.
8Datei-Uploads in Symfony REST APIs?
Multipart/form-data statt JSON. ArgumentValueResolver überspringt Deserializer und holt Datei aus $request->files. Eigenes UploadRequest-DTO mit Factory-Pattern.
9Idempotency Keys für POST-Endpunkte?
Client sendet Idempotency-Key-Header. Server speichert Request+Response unter dem Key (Redis). Bei Folge-Requests gespeicherte Response zurückgeben. EventSubscriber prüft Key vor dem Controller.
10Symfony API-Request debuggen?
Correlation ID aus Problem-Details in Logs suchen. Symfony Profiler über X-Debug-Token-Header aufrufen – zeigt DB-Abfragen, Events und Performance für den Request.