SF
{ }
Symfony 7 · API-Versionierung · REST · Breaking Changes
Symfony API-Versionierung
Strategien ohne Breaking Changes

Jede öffentliche API muss irgendwann geändert werden. Die Frage ist nicht ob, sondern wie — ohne bestehende Clients zu brechen. API-Versionierung ist keine reine Architekturentscheidung, sondern ein Betriebsprozess: Von der URI-Strategie über Content Negotiation bis hin zu strukturierten Deprecation-Zyklen entscheidet die Strategie darüber, wie viel technische Schuld eine API über die Zeit ansammelt.

17 Min. Lesezeit URI-Versionierung · Header-Versionierung · Content Negotiation · Deprecation Symfony 7.x · API Platform 4 · PHP 8.4

1. Warum API-Versionierung notwendig ist

Eine API ist ein Vertrag zwischen dem Server und seinen Clients. Ändert man diesen Vertrag einseitig — umbenennen eines Felds, ändern eines Datentyps, entfernen eines Endpunkts — brechen alle Clients, die sich auf das alte Verhalten verlassen. In internen Projekten kann man alle Clients kennen und koordiniert migrieren. Bei öffentlichen APIs, Partner-APIs oder mobilen Anwendungen, die der Nutzer nicht sofort aktualisiert, ist unkoordinierte Änderung keine Option. API-Versionierung ist der Mechanismus, der Serverentwicklung und Client-Updates entkoppelt.

Das Kernproblem ist, dass Breaking Changes in REST-APIs schwer zu vermeiden sind, sobald eine API wächst. Felder werden umbenannt, weil die ursprüngliche Semantik unklar war. Datentypen ändern sich, weil eine Integer-ID zu einem String-UUID migriert. Endpunkte werden aufgeteilt, weil eine Resource zu komplex geworden ist. Ohne API-Versionierung akkumulieren sich diese Änderungen zu technischer Schuld, die irgendwann unwartbar wird. Mit durchdachter API-Versionierung bleibt jede Version stabil, und Clients migrieren auf eigene Initiative — auf einem kommunizierten Deprecation-Zeitplan.

2. URI-Versionierung: /api/v1/ vs. /api/v2/

URI-Versionierung ist die bekannteste und am häufigsten eingesetzte API-Versionierung-Strategie: Der Version-Identifier ist Teil des Pfads, zum Beispiel /api/v1/products und /api/v2/products. Der Vorteil ist offensichtlich: Die Version ist in jedem Request sichtbar, in Logs leicht identifizierbar und ohne spezielle Client-Konfiguration nutzbar. Browser und einfache HTTP-Clients können beide Versionen gleichzeitig ansprechen. Für öffentliche APIs ist das die weitaus entwicklerfreundlichste Strategie — keine Headermagie, kein Content-Type-Parsing.

In Symfony setzt man URI-Versionierung über Routing um: Separate Controller pro Version oder ein gemeinsamer Controller mit Versions-Routing-Prefix. Die sauberere Variante sind separate Controller, die jeweils die Geschäftslogik aus gemeinsamen Services beziehen. Das vermeidet Code-Duplikation in der Logik-Schicht und hält die Controller schlank. Ein Versions-Routing-Prefix in routes/api.yaml bündelt alle v1-Routen unter einem Präfix, ohne jeden Controller einzeln anpassen zu müssen. Das Routing-System von Symfony erlaubt diese Bündelung über prefix: /api/v1 in der Routing-Ressource-Konfiguration.


<?php

declare(strict_types=1);

namespace App\Controller\Api\V1;

use App\Dto\V1\ProductResponse;
use App\Repository\ProductRepository;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\Routing\Attribute\Route;

/**
 * Product API controller — Version 1.
 * Returns products with the V1 response format (integer ID, simple category string).
 */
#[Route('/api/v1/products', name: 'api_v1_product_')]
final class ProductController extends AbstractController
{
    public function __construct(
        private readonly ProductRepository $productRepository,
    ) {}

    /**
     * List products in V1 format — category returned as string name.
     */
    #[Route('', name: 'list', methods: ['GET'])]
    public function list(): JsonResponse
    {
        $products = $this->productRepository->findAll();

        // V1 format: flat structure, category as string, integer ID
        $data = array_map(fn ($p) => [
            'id'       => $p->getId(),
            'name'     => $p->getName(),
            'price'    => $p->getPrice(),
            'category' => $p->getCategory()?->getName(),  // V1: string, not object
        ], $products);

        return $this->json($data);
    }
}

// Separate namespace for V2 — breaking change: category is now an object
// namespace App\Controller\Api\V2;
// #[Route('/api/v2/products', name: 'api_v2_product_')]
// V2 format: category as object { id, name, slug }
// V1 remains untouched — clients can migrate at their own pace

Header-basierte API-Versionierung hält die URI sauber und verlagert die Versionsangabe in den HTTP-Accept-Header oder einen Custom-Header. Das entspricht der REST-Philosophie stärker als URI-Versionierung, weil eine Resource eine einzige URI hat — unabhängig davon, in welchem Format sie zurückgegeben wird. In der Praxis sieht der Accept-Header dann so aus: Accept: application/vnd.myapi.v2+json. Der Vendor-MIME-Typ kodiert die Version und das Format. Symfony kann in einem Content-Negotiation-Listener den Header auslesen und die Verarbeitungslogik entsprechend steuern.

Die Herausforderung der Header-Versionierung liegt in der Client-Implementierung: Jeder HTTP-Client muss explizit den richtigen Accept-Header setzen. Browser-Formulare, einfache Curl-Aufrufe und Tools, die HTTP-Standard-Headers verwenden, bekommen die Default-Version. Das ist in manchen Projekten akzeptabel — in anderen führt es zu subtilen Fehlern, wenn Clients vergessen, den Header zu setzen und unerwartet auf einer alten Version landen. Custom-Header wie X-API-Version: 2 sind einfacher zu implementieren, aber kein HTTP-Standard und werden von manchen Proxies gestripped.

4. Content Negotiation in Symfony implementieren

Symfony bietet für Content Negotiation einen eingebauten Mechanismus über den Request-Objekt: $request->getPreferredFormat() analysiert den Accept-Header und gibt das bevorzugte Format zurück. Für API-Versionierung über Accept-Header implementiert man einen Kernel-Event-Listener, der den Accept-Header auf Versions-Informationen analysiert und das Ergebnis als Request-Attribut speichert. Controller können dann über $request->attributes->get('api_version') auf die Version zugreifen, ohne den Header selbst zu parsen.

Ein saubereres Muster für Content-Negotiation-basierte API-Versionierung nutzt Symfony's FormatListener aus dem FOSRestBundle oder implementiert einen eigenen EventSubscriber auf KernelEvents::REQUEST. Der Subscriber parst den Accept-Header, extrahiert die Version und setzt sie als Request-Attribut. Downstream-Handler — Controller, Serializer-Context-Builder — lesen dieses Attribut aus. Das hält die Versionslogik an einem einzigen Ort und vermeidet Duplikation in jedem Controller. Die Kombination aus Request-Attribut und Serialisierungsgruppen macht Content-Negotiation zu einer eleganten API-Versionierung-Strategie für Symfony.


<?php

declare(strict_types=1);

namespace App\EventSubscriber;

use Symfony\Component\EventDispatcher\EventSubscriberInterface;
use Symfony\Component\HttpKernel\Event\RequestEvent;
use Symfony\Component\HttpKernel\KernelEvents;

/**
 * Extracts API version from Accept header and stores it as request attribute.
 * Supports: Accept: application/vnd.mironsoft.v2+json
 * Falls back to version 1 if no version header is present.
 */
final class ApiVersionSubscriber implements EventSubscriberInterface
{
    private const DEFAULT_VERSION = 1;
    private const VENDOR_MIME_PATTERN = '/application\/vnd\.mironsoft\.v(\d+)\+json/';

    public static function getSubscribedEvents(): array
    {
        return [
            // Priority 20: run before controller resolution
            KernelEvents::REQUEST => [['onKernelRequest', 20]],
        ];
    }

    public function onKernelRequest(RequestEvent $event): void
    {
        $request = $event->getRequest();

        // Only process API routes
        if (!str_starts_with($request->getPathInfo(), '/api/')) {
            return;
        }

        $acceptHeader = $request->headers->get('Accept', '');
        $version = self::DEFAULT_VERSION;

        // Extract version from vendor MIME type: application/vnd.mironsoft.v2+json
        if (preg_match(self::VENDOR_MIME_PATTERN, $acceptHeader, $matches)) {
            $version = (int) $matches[1];
        }

        // Also check custom header as fallback: X-API-Version: 2
        if ($request->headers->has('X-API-Version')) {
            $version = (int) $request->headers->get('X-API-Version');
        }

        // Store as request attribute — accessible in controllers and listeners
        $request->attributes->set('api_version', $version);
    }
}

5. Evolutionäre API: Erweiterung ohne Breaking Changes

Die beste API-Versionierung-Strategie ist die, die man selten benötigt — weil die API von Anfang an für Evolution gebaut wurde. Das Prinzip der evolutionären API: Felder werden niemals umbenannt oder entfernt, nur hinzugefügt. Neue Felder sind optional und haben sinnvolle Defaults. Datentypen werden nie geändert — wenn eine ID von Integer zu String wechseln muss, wird ein neues Feld uuid hinzugefügt, während das alte id-Feld als Integer bleibt. Clients, die nur id kennen, funktionieren weiterhin. Neue Clients können uuid verwenden.

Postel's Law — "Be conservative in what you send, be liberal in what you accept" — ist das fundamentale Designprinzip hinter evolutionären APIs. Auf der Empfangsseite: Felder, die der Server nicht kennt, werden ignoriert (kein additionalProperties: false in JSON Schema). Auf der Ausgabeseite: Immer vollständig, nie willkürlich reduziert. Wenn ein Client ein unbekanntes optionales Feld sendet, gibt der Server kein Validierungsfehler zurück. Wenn der Server ein neues optionales Feld sendet, crasht der Client nicht. Diese zwei Prinzipien zusammen ermöglichen unabhängige Versionierung von Server und Clients — das Ziel jeder guten API-Versionierung-Strategie.

6. Versionsabhängige Serialisierung mit Symfony Groups

Symfony's Serializer-Gruppen sind ein mächtiges Werkzeug für API-Versionierung ohne Code-Duplikation. Statt separater Controller-Klassen pro Version steuert man über Serialisierungsgruppen, welche Felder in welcher API-Version sichtbar sind. Eine Property mit #[Groups(['product:read:v1', 'product:read:v2'])] erscheint in beiden Versionen. Eine neue Property mit #[Groups(['product:read:v2'])] erscheint nur in v2. Eine zu entfernende Property behält ihre v1-Gruppe und verliert die v2-Gruppe — sie wird für neue Clients unsichtbar, ohne alten Clients zu schaden.

Der Schlüssel ist ein Serializer-Context-Builder, der die API-Version aus dem Request-Attribut liest und die entsprechende Serialisierungsgruppe aktiviert. In Verbindung mit dem ApiVersionSubscriber aus Abschnitt 4 ist die gesamte Versionierungslogik in zwei Klassen konzentriert — Controller und Entities bleiben unberührt. Das ist der sauberste Ansatz für API-Versionierung in Symfony: kein URL-Routing-Wildwuchs, keine doppelten Controller, keine manuellen Response-Transformationen. Nur Serialisierungsgruppen, die deklarativ steuern, was in welcher Version sichtbar ist.


<?php

declare(strict_types=1);

namespace App\Entity;

use Doctrine\ORM\Mapping as ORM;
use Symfony\Component\Serializer\Attribute\Groups;

#[ORM\Entity]
class Product
{
    #[ORM\Id, ORM\GeneratedValue, ORM\Column]
    // V1 and V2: integer ID always present
    #[Groups(['product:read:v1', 'product:read:v2'])]
    private ?int $id = null;

    #[ORM\Column(type: 'uuid')]
    // V2 only: new UUID field — V1 clients don't see this, won't break
    #[Groups(['product:read:v2'])]
    private ?string $uuid = null;

    #[ORM\Column(length: 255)]
    #[Groups(['product:read:v1', 'product:read:v2'])]
    private string $name = '';

    // V1 only: category as flat string — V2 uses the full category object
    #[Groups(['product:read:v1'])]
    private ?string $categoryName = null;

    // V2 only: full category object — replaces V1 categoryName
    #[Groups(['product:read:v2'])]
    #[ORM\ManyToOne(targetEntity: Category::class)]
    private ?Category $category = null;

    // ... getters and setters

    /** Compute V1 flat category name from the category relation */
    public function getCategoryName(): ?string
    {
        return $this->category?->getName();
    }
}

// Context builder: maps api_version request attribute to serialization group
// Inject into SerializerContextBuilder and read $request->attributes->get('api_version')
// Active group: "product:read:v" . $version

7. Deprecation-Strategie: Alte Versionen kommunizieren

Eine API-Versionierung-Strategie ohne Deprecation-Kommunikation ist unvollständig. Clients müssen wissen, wann eine alte Version abgeschaltet wird — und zwar mit ausreichend Vorlauf. Die technische Umsetzung in Symfony: Ein Response-Subscriber fügt bei Requests auf veraltete API-Versionen einen Deprecation-Header nach RFC 8594 hinzu. Der Header enthält den Datum-Zeitpunkt, ab dem die Version als deprecated gilt, und optional einen Link zum Sunset-Datum und zur Migrations-Dokumentation. Clients, die auf diese Header achten, können automatisch warnen.

Der Sunset-Header (RFC 8594) ergänzt den Deprecation-Header und kommuniziert das genaue Abschalt-Datum einer Version: Sunset: Sat, 1 Jan 2027 00:00:00 GMT. In Monitoring-Tools kann man auf das Verschwinden von Requests gegen eine deprecated Version warten, um sicher zu sein, dass alle Clients migriert haben. Serverseite Zugriffs-Logs aggregiert nach Version geben ein klares Bild, welcher Anteil der Requests noch auf alten Versionen liegt. Diese Daten informieren die Entscheidung, wann eine Version tatsächlich abgeschaltet werden kann — ohne Blindflug.

8. API-Versionierung mit API Platform 4

API Platform 4 bietet für API-Versionierung mehrere Einstiegspunkte. Der empfohlene Ansatz ist die Nutzung separater Ressourcen-Klassen pro Version — keine doppelte Entity, aber separate Output-DTOs. Ein ProductOutputV1 und ein ProductOutputV2 DTO repräsentieren die jeweiligen Versionen. Der State Provider transformiert die Entity in das versionsspezifische Output-DTO. API Platform serialisiert das DTO und gibt es zurück. Der Controller-Code bleibt identisch — nur das DTO ändert sich zwischen den Versionen.

URI-Versionierung in API Platform 4 setzt man über separate API-Präfixe: api_platform: prefix: /v1 für v1-Ressourcen und ein zweites Bundle-Mounting für v2-Ressourcen unter /v2. Alternativ kann man den uriTemplate-Parameter auf der Operation direkt setzen: new GetCollection(uriTemplate: '/v2/products') auf einer v2-Ressource. Der Vorteil dieses Ansatzes: API Platform generiert automatisch OpenAPI-Dokumentation für beide Versionen — der Unterschied zwischen v1 und v2 ist in der OpenAPI-Spec sofort sichtbar. Das erleichtert die Client-Migration erheblich, weil Entwickler die Änderungen direkt in der Swagger-UI vergleichen können.

9. Versionierungsstrategien im Vergleich

Die Wahl der richtigen API-Versionierung-Strategie hängt vom Projektyp, der Zielgruppe und dem Team ab. Die Tabelle zeigt die wichtigsten Tradeoffs.

Strategie Vorteil Nachteil Geeignet für
URI /v1/ /v2/ Sichtbar, einfach, debuggbar URI-Verschmutzung, REST-kontra Öffentliche APIs, Entwickler-freundlich
Accept-Header REST-konform, saubere URIs Komplexe Client-Konfiguration Internal APIs, erfahrene Clients
Custom Header X-API-Version Einfache Client-Implementierung Kein HTTP-Standard, Proxy-Risiko Partner-APIs, kontrollierte Umgebung
Evolutionäre API Kein Breaking Change, kein Versioning Erfordert Disziplin im API-Design Langlebige APIs, stabiles Datenmodell
Symfony Groups Kein Code-Duplikat, deklarativ Gruppen-Management komplex bei vielen Versionen Field-Level-Änderungen, API Platform

In der Praxis kombinieren die meisten erfolgreichen Symfony-APIs mehrere Strategien: URI-Versionierung für Major-Versionen, die echte Breaking Changes enthalten, und evolutionäre Erweiterung für Minor-Änderungen innerhalb einer Version. Symfony Groups steuern die feingranulare Feldauswahl pro Version, ohne separate Controller zu erfordern. Der Deprecation-Header kommuniziert den Abschaltzeitpunkt. Diese Kombination gibt Clients maximale Stabilität und dem Server-Team die Freiheit, die API weiterzuentwickeln.

Mironsoft

Symfony API-Architektur, Versionierungsstrategien und Breaking-Change-freie Migration

Symfony-API mit klarer Versionierungsstrategie aufbauen oder migrieren?

Wir entwickeln Symfony-APIs mit durchdachter Versionierungsstrategie — von der initialen Architekturentscheidung über evolutionäre Erweiterungsmuster bis zur strukturierten Deprecation-Kommunikation für eure Clients.

API-Architektur

Versionierungsstrategie, evolutionäres API-Design und Deprecation-Planung für eure Symfony-API

Breaking-Change-Migration

Strukturierte Migration bestehender APIs auf neue Versionen ohne Client-Downtime

API Platform Integration

Versionierung in API Platform 4 mit Output-DTOs, Serializer-Gruppen und automatischer OpenAPI-Doku

10. Zusammenfassung

API-Versionierung in Symfony ist keine einzelne Technik, sondern ein Set von Entscheidungen, die zusammen eine wartbare, client-freundliche API ergeben. URI-Versionierung (/api/v1/) ist die einfachste und entwicklerfreundlichste Option für öffentliche APIs. Header-Versionierung über Accept-Header ist REST-konformer, aber erfordert mehr Client-Disziplin. Evolutionäres API-Design — neue Felder hinzufügen, alte nie entfernen — reduziert den Bedarf an expliziter Versionierung auf echte Breaking Changes. Symfony Serializer Groups implementieren feingranulare Feldauswahl pro Version ohne Controller-Duplikation.

Die Deprecation-Strategie ist der unterschätzte Teil jeder API-Versionierung: Ohne klaren Kommunikationsplan zu Sunset-Daten und Migrations-Pfaden bleiben alte Versionen ewig leben, weil niemand weiß, ob noch Clients darauf angewiesen sind. RFC 8594 Deprecation- und Sunset-Header, kombiniert mit Zugriffslog-Monitoring nach API-Version, geben das Datenfundament für Abschaltentscheidungen. Gute API-Versionierung ist technisch lösbar — der menschliche Teil ist die Disziplin, die Strategie von Beginn an durchzuhalten.

Symfony API-Versionierung — Das Wichtigste auf einen Blick

URI-Versionierung

/api/v1/ und /api/v2/ — sichtbar, debuggbar, entwicklerfreundlich. Beste Wahl für öffentliche APIs. Symfony Routing-Präfix bündelt alle Versionsrouten.

Evolutionäre API

Felder nur hinzufügen, nie entfernen oder umbenennen. Neue Felder optional mit Defaults. Postel's Law: liberal empfangen, konservativ senden.

Symfony Groups

#[Groups(['product:read:v1'])] steuert Feldauswahl pro Version deklarativ. Kein Code-Duplikat, keine doppelten Controller — Versionslogik in Serializer-Kontext.

Deprecation

Deprecation- und Sunset-Header (RFC 8594) kommunizieren Abschalt-Datum. Zugriffslog-Monitoring nach Version — Abschalten erst wenn kein Traffic mehr.

11. FAQ: API-Versionierung in Symfony

1Was ist API-Versionierung?
Mechanismus, der API-Weiterentwicklung ohne Clients zu brechen ermöglicht. Entkoppelt Server-Entwicklung von Client-Updates durch Versionsnummern und Deprecation-Kommunikation.
2URI- vs. Header-Versionierung?
URI (/api/v1/): sichtbar, einfach, entwicklerfreundlich. Header (Accept: application/vnd.api.v2+json): REST-konformer, komplexere Clients. URI für öffentliche APIs empfohlen.
3URI-Versionierung in Symfony implementieren?
Separate Controller-Namespaces per Version mit #[Route('/api/v1/...')] oder Routing-Ressourcen mit prefix. Services und Geschäftslogik zwischen Versionen teilen.
4Was ist evolutionäres API-Design?
Felder nur hinzufügen, nie umbenennen oder entfernen. Neue Felder optional mit Defaults. Clients ignorieren unbekannte Felder — reduziert Bedarf an expliziter Versionierung.
5Symfony Serializer Groups für Versionierung?
#[Groups(['product:read:v1'])] für v1-exklusive Felder. Context-Builder aktiviert korrekte Gruppe basierend auf api_version-Request-Attribut. Kein Code-Duplikat.
6Was ist der Deprecation-Header?
RFC 8594: Deprecation: "2026-01-01" kommuniziert Veraltung. Sunset: "2027-01-01" kommuniziert Abschaltdatum. Response-Subscriber fügt Header auf deprecated Versionen automatisch hinzu.
7Versionierung mit API Platform 4?
Separate Output-DTOs pro Version, State Provider transformiert Entity. uriTemplate-Parameter für Versionspfade. OpenAPI-Doku automatisch für beide Versionen generiert.
8Wann neue API-Version einführen?
Bei echten Breaking Changes: Felder umbenennen/entfernen, Datentypen ändern, Endpunktstruktur ändern. Neue optionale Felder sind kein Grund — evolutionär einbauen.
9Wie lange deprecated API-Version betreiben?
Mindestens 6-12 Monate nach Ankündigung. Sunset-Header kommuniziert Datum. Log-Monitoring nach Version — abschalten erst wenn kein Traffic mehr auf alter Version.
10API-Versionierung ohne Code-Duplikation?
Ja — Symfony Serializer Groups steuern Feldauswahl per Version deklarativ. Business-Logik in geteilten Services. Nur Präsentationsschicht (DTOs oder Groups) unterscheidet sich zwischen Versionen.