{ }
GET
REST API · Versionierung · Symfony · OpenAPI · Backward Compatibility
Versionierung in REST APIs ohne Chaos
URL, Header, Sunset und Backward Compatibility

Ungeplante API-Versionierung endet mit /v1, /v2, /v2-new, /v3-beta und einem Team, das nicht mehr weiß, welche Version welche Clients noch nutzen. Klare Versionierungsstrategien, Sunset-Header und Backward-Compatibility-Muster verhindern, dass Versionen zum Wartungsproblem werden.

14 Min. Lesezeit URL-Versioning · Header-Versioning · Sunset-Header · Symfony-Routing · OpenAPI Symfony 6.x / 7.x · PHP 8.2+

1. Warum API-Versionierung ein strategisches Problem ist

Die meisten Teams beginnen ohne explizite Versionierungsstrategie. Wenn der erste Breaking Change kommt – ein umbenanntes Feld, eine geänderte Response-Struktur, eine entfernte Eigenschaft – entsteht die erste Ad-hoc-Entscheidung: Prefix /v2 an alle Routen hängen und weitermachen. Sechs Monate später existieren /v1 und /v2 parallel, die Business-Logik ist zwischen beiden Versionen dupliziert, und niemand weiß, welche Clients noch auf /v1 zugreifen.

Das eigentliche Problem ist nicht die Versionierung selbst, sondern das Fehlen einer klaren Deprecation-Strategie und eines definierten Lebenszyklusmodells. Wann wird eine Version als deprecated markiert? Wie lange wird sie noch unterstützt? Wer informiert die API-Consumer? Ohne Antworten auf diese Fragen wächst die Anzahl parallel betriebener Versionen mit jedem Breaking Change – bis der Wartungsaufwand die Feature-Entwicklung übersteigt.

2. URL-Versionierung: /v1, /v2 – Vor- und Nachteile

URL-Versionierung ist die am häufigsten eingesetzte Strategie: Der Versionspräfix steht im Pfad, z.B. /api/v1/orders und /api/v2/orders. Der größte Vorteil ist Sichtbarkeit: Die Version ist in jedem Request-Log, jedem Monitoring-Dashboard und jeder Browser-Adresszeile sofort erkennbar. Entwickler können beide Versionen gleichzeitig in Browser-Tabs offen haben, Logs nach Version filtern und Cache-Systeme einfach pro Version konfigurieren.

Die Nachteile sind ebenso real: Technisch gesehen verstößt URL-Versionierung gegen das REST-Prinzip, dass eine URI eine eindeutige Ressource identifiziert – /v1/orders/42 und /v2/orders/42 sind dieselbe Order, nur in unterschiedlichen Darstellungen. In der Praxis ist das ein akademisches Argument, das kaum zählt. Schwerwiegender ist, dass URL-Versionierung Clients zwingt, beim Versions-Upgrade alle Basis-URLs in ihrer Konfiguration zu ändern – das ist ein echter Migrationsaufwand, besonders für mobile Apps mit langem Release-Zyklus.


<?php
// src/Controller/Api/V1/OrderController.php
declare(strict_types=1);

namespace App\Controller\Api\V1;

use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\Routing\Attribute\Route;

/**
 * Order API controller – version 1 (deprecated, sunset 2026-12-31).
 */
#[Route('/api/v1/orders', name: 'api_v1_orders_')]
final class OrderController extends AbstractController
{
    #[Route('/{id}', name: 'show', methods: ['GET'])]
    public function show(int $id): JsonResponse
    {
        // V1 response format: flat structure with customer_name
        $response = $this->json([
            'id'            => $id,
            'customer_name' => 'Max Mustermann',  // deprecated field
            'total'         => 149.99,
            'status'        => 'shipped',
        ]);

        // Sunset and Deprecation headers inform clients
        $response->headers->set('Sunset', 'Sat, 31 Dec 2026 23:59:59 GMT');
        $response->headers->set('Deprecation', 'true');
        $response->headers->set('Link', '</api/v2/orders/' . $id . '>; rel="successor-version"');

        return $response;
    }
}

// src/Controller/Api/V2/OrderController.php
declare(strict_types=1);

namespace App\Controller\Api\V2;

use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\Routing\Attribute\Route;

/**
 * Order API controller – version 2 (current stable).
 */
#[Route('/api/v2/orders', name: 'api_v2_orders_')]
final class OrderController extends AbstractController
{
    #[Route('/{id}', name: 'show', methods: ['GET'])]
    public function show(int $id): JsonResponse
    {
        // V2 response format: nested customer object
        return $this->json([
            'id'     => $id,
            'customer' => [
                'name'  => 'Max Mustermann',
                'email' => 'max@example.com',
            ],
            'total'  => 149.99,
            'status' => 'shipped',
            'items'  => [],
        ]);
    }
}

3. Header-Versionierung: Accept und API-Version Header

Header-basierte Versionierung hält die URL sauber: GET /api/orders/42 bleibt dieselbe URL, die Version wird über einen Header kommuniziert. Zwei verbreitete Varianten: Der Accept-Header mit Media-Type-Versionierung (Accept: application/vnd.mironsoft.v2+json) folgt streng dem HTTP-Standard und nutzt Content Negotiation. Der pragmatischere Custom-Header Api-Version: 2 ist einfacher zu debuggen und zu loggen, ist aber nicht Teil des HTTP-Standards.

Der wichtigste Nachteil von Header-Versionierung: Sie ist für Entwickler weniger sichtbar. In Browser-Adresszeilen, API-Logs ohne Header-Ausgabe und Monitoring-Dashboards sieht man die Version nicht sofort. Das Debuggen von Problemen, bei denen Client A Version 1 und Client B Version 2 aufruft, wird aufwändiger. Für interne APIs zwischen Teams desselben Unternehmens ist Header-Versionierung oft die bessere Wahl. Für public APIs mit vielen unterschiedlichen Clients überwiegen die Vorteile der URL-Versionierung.

4. Symfony-Routing für mehrere API-Versionen

In Symfony gibt es mehrere Ansätze, mehrere API-Versionen zu routen. Der sauberste für URL-Versionierung: separate Controller-Namespaces (App\Controller\Api\V1, App\Controller\Api\V2) mit gemeinsam genutzten Services und DTOs, die sich nur in ihrer Serialisierung unterscheiden. Die Business-Logik liegt in Services, nicht in Controllern – Versionsunterschiede betreffen fast immer nur das Request/Response-Format, nicht die Domänenlogik.

Für Header-Versionierung eignet sich ein Request-Attribute-Listener, der die angeforderte Version aus dem Header liest und als Request-Attribut setzt. Das Routing kann dann über condition-Ausdrücke auf den Header reagieren, oder ein Event-Listener leitet den Request intern an den richtigen Handler weiter.


# config/routes/api_v1.yaml
api_v1:
    resource: '../../src/Controller/Api/V1/'
    type: attribute
    prefix: /api/v1
    defaults:
        _api_version: '1'
    # Add deprecation response middleware via event listener

# config/routes/api_v2.yaml
api_v2:
    resource: '../../src/Controller/Api/V2/'
    type: attribute
    prefix: /api/v2
    defaults:
        _api_version: '2'

# config/routes/api_v3.yaml — content negotiation approach
api_v3_orders:
    path: /api/orders/{id}
    controller: App\Controller\Api\V3\OrderController::show
    methods: [GET]
    condition: "request.headers.get('Api-Version') === '3'"

# Alternative: route versioning via Accept header media type
# Accept: application/vnd.mironsoft.v2+json
# Requires custom RequestMatcher or Kernel listener

5. Backward Compatibility: Was Breaking Change bedeutet

Ein Breaking Change in einer REST API ist jede Änderung, die bestehende Clients ohne Code-Anpassung kaputt macht. Klassische Breaking Changes: Pflichtfeld aus Response entfernen, Feldname ändern (customer_namecustomer.name), Datentyp ändern (String → Integer), HTTP-Status ändern (200 → 201), URL-Struktur ändern. Alles andere sind Non-Breaking Changes: optionale Felder hinzufügen, neue optionale Request-Parameter, neue Endpunkte, neue HTTP-Methoden auf bestehenden Routen.

Die wichtigste Regel: Hinzufügen ist fast immer sicher, Entfernen und Umbenennen ist immer ein Breaking Change. Clients müssen zusätzliche Felder ignorieren können (Robustness Principle). Das bedeutet: Keine stricte Schema-Validierung auf Client-Seite, und API-Schemas müssen additionalProperties: true erlauben. Wer diese Regel konsequent befolgt, kann viele geplante Breaking Changes als Non-Breaking Changes implementieren, indem das alte Feld weiter mitgeliefert wird, während das neue Feld hinzukommt.


// Non-Breaking Change: Neues Feld hinzufügen
// V1 Response (weiterhin gültig):
{
  "id": 42,
  "customer_name": "Max Mustermann",
  "total": 149.99
}

// V1 Response nach Non-Breaking Change (altes Feld bleibt):
{
  "id": 42,
  "customer_name": "Max Mustermann",
  "customer": {
    "name": "Max Mustermann",
    "email": "max@example.com"
  },
  "total": 149.99
}

// Breaking Change in V2 (altes Feld entfernt):
{
  "id": 42,
  "customer": {
    "name": "Max Mustermann",
    "email": "max@example.com"
  },
  "total": 149.99
}

// Strategie: In V1 beide Felder liefern, Sunset-Datum kommunizieren,
// erst in V2 das alte Feld entfernen. So haben Clients Zeit zur Migration.

6. Sunset-Header und Deprecation-Kommunikation

Der Sunset-Header (RFC 8594) ist das Werkzeug, um API-Konsumenten über bevorstehende Abschaltungen zu informieren. Er enthält ein HTTP-Datum, nach dem der Endpunkt oder die Version nicht mehr verfügbar sein wird. Zusammen mit dem Deprecation-Header (RFC 9745) und einem Link-Header mit rel="successor-version" haben Clients alle Informationen, die sie für eine Migration brauchen – maschinenlesbar, ohne auf Dokumentation angewiesen zu sein.

Das Sunset-Datum sollte realistische Migrationszeit einkalkulieren: mindestens 6 Monate für interne APIs, 12 Monate oder mehr für Public APIs mit unbekanntem Client-Ökosystem. Ein Event-Listener, der den Sunset-Header automatisch für alle Anfragen an versaltete Routen setzt, verhindert, dass einzelne Endpunkte vergessen werden. Monitoring-Tools wie Prometheus können auf den Sunset-Header reagieren und Alerts erzeugen, bevor das Abschaltdatum erreicht wird.


<?php
// src/EventListener/ApiDeprecationListener.php
declare(strict_types=1);

namespace App\EventListener;

use Symfony\Component\HttpKernel\Event\ResponseEvent;

/**
 * Automatically adds Sunset and Deprecation headers to responses
 * for deprecated API versions, based on route attribute _api_version.
 */
final class ApiDeprecationListener
{
    /** @var array<string, string> Map: API version => Sunset date (RFC 7231 format) */
    private const SUNSET_DATES = [
        '1' => 'Sat, 31 Dec 2026 23:59:59 GMT',
    ];

    public function onKernelResponse(ResponseEvent $event): void
    {
        if (!$event->isMainRequest()) {
            return;
        }

        $request  = $event->getRequest();
        $version  = $request->attributes->get('_api_version');
        $response = $event->getResponse();

        if (!isset(self::SUNSET_DATES[$version])) {
            return;
        }

        $sunsetDate = self::SUNSET_DATES[$version];
        $response->headers->set('Sunset', $sunsetDate);
        $response->headers->set('Deprecation', 'true');

        // Link to migration guide
        $response->headers->set(
            'Link',
            sprintf(
                '<https://mironsoft.de/api/migration/v%s-to-v%d>; rel="deprecation"',
                $version,
                (int) $version + 1
            )
        );
    }
}

7. Mehrere API-Versionen in OpenAPI dokumentieren

Die verbreitetste Praxis ist eine eigene OpenAPI-Datei pro Version: openapi-v1.yaml, openapi-v2.yaml. Das ist wartbar und vermeidet, dass sich Versions-Unterschiede in einer einzigen großen Spezifikation verstecken. Gemeinsame Schemas (z.B. ProblemDetails, Pagination) werden in einer separaten Datei verwaltet und in beiden Versionen per $ref referenziert.

Swagger UI und Redoc unterstützen Versions-Switcher: Entweder über separate URLs (/api-docs/v1, /api-docs/v2) oder über einen Dropdown im UI. In der OpenAPI-Datei der veralteten Version gehört ein x-deprecated: true-Extensionsfeld und ein Hinweis in der info.description mit Sunset-Datum und Link zur Nachfolgerversion.

8. Versionierungsstrategien im Vergleich

Keine Versionierungsstrategie ist universell richtig. Die Wahl hängt vom Typ der API (intern / public), dem Client-Ökosystem und den Anforderungen an die Abwärtskompatibilität ab.

Strategie Sichtbarkeit Cache-freundlich REST-konform Empfehlung
URL-Prefix (/v1, /v2) Sehr hoch Ja Diskutiert Public APIs
Custom Header (Api-Version) Mittel Nur mit Vary Pragmatisch Interne APIs
Accept-Header (vnd. Media Types) Niedrig Mit Vary Hoch REST-Puristen
Query-Parameter (?version=2) Hoch Ja Niedrig Vermeiden
Keine Versionierung (Evergreen API) Ja Ja Nur mit strikter Backward Compat.

Mironsoft

REST API Design, Symfony Backend-Entwicklung und OpenAPI-Dokumentation

API-Versionierung strategisch aufsetzen?

Wir analysieren eure bestehende API-Struktur, definieren eine klare Versionierungsstrategie und implementieren Sunset-Header, Deprecation-Monitoring und Migration-Guides für geordnete Versionsübergänge.

Strategie-Review

Analyse bestehender Versionsstrukturen und Empfehlung der passenden Strategie

Implementierung

Symfony-Routing, Deprecation-Listener und automatische Sunset-Header

OpenAPI-Docs

Separate OpenAPI-Dateien pro Version mit Migration-Guides und Deprecation-Hinweisen

9. Zusammenfassung

REST API Versionierung ohne Chaos erfordert drei Entscheidungen vor dem ersten Breaking Change: Welche Strategie (URL vs. Header), welches Lebenszyklusmodell (wie lange werden Versionen unterstützt), und wie werden Clients über Deprecation informiert. URL-Versionierung ist für die meisten Public APIs die pragmatisch beste Wahl – sie ist sichtbar, debuggbar und Cache-freundlich. Header-Versionierung eignet sich für interne APIs, wo alle Clients kontrolliert sind.

Sunset-Header kommunizieren Abschalttermine maschinenlesbar. Backward-Compatibility-Analyse vor jeder API-Änderung verhindert unnötige Breaking Changes. Und eine klare Trennung: Business-Logik in Services, Versions-Unterschiede nur in Controllers und DTOs. So bleibt das Codebase wartbar, auch wenn V1 und V2 parallel betrieben werden.

API-Versionierung ohne Chaos — Das Wichtigste auf einen Blick

Strategie früh festlegen

URL-Versionierung für Public APIs (/v1, /v2). Header-Versionierung für interne APIs. Query-Parameter immer vermeiden.

Breaking vs. Non-Breaking

Hinzufügen ist safe, Entfernen und Umbenennen ist breaking. Alte Felder parallel mitliefern, Sunset-Datum kommunizieren, dann in V2 entfernen.

Sunset-Header (RFC 8594)

Maschinenlesbares Abschaltdatum. Zusammen mit Deprecation-Header und Link: rel="successor-version" für automatisierte Client-Alerts.

OpenAPI pro Version

Separate openapi-v1.yaml, openapi-v2.yaml. Gemeinsame Schemas als $ref. Deprecated-Version mit x-deprecated: true markieren.

10. FAQ: Versionierung in REST APIs ohne Chaos

1URL-Versionierung oder Header – was ist besser?
Für Public APIs URL-Versionierung (/v1, /v2): sichtbar, debuggbar, Cache-freundlich. Header-Versionierung für interne APIs. Query-Parameter immer vermeiden.
2Was ist ein Breaking Change?
Felder entfernen, umbenennen, Datentypen ändern, HTTP-Status ändern. Felder hinzufügen ist fast immer safe – Clients müssen unbekannte Felder ignorieren können.
3Was macht der Sunset-Header?
RFC 8594: maschinenlesbares Abschaltdatum. Monitoring-Tools und API-Clients können automatisch warnen, bevor das Datum erreicht wird.
4Wie lange soll eine deprecated Version leben?
Interne APIs: mindestens 6 Monate. Public APIs: mindestens 12 Monate. Mobile App Release-Zyklen einkalkulieren.
5Symfony-Implementierung?
Separate Controller-Namespaces, Routing über YAML mit prefix, Event-Listener setzt Sunset-Header automatisch für deprecated Versionen.
6Eigene OpenAPI-Datei pro Version?
Ja. openapi-v1.yaml und openapi-v2.yaml getrennt. Gemeinsame Schemas in einer dritten Datei per $ref einbinden. Deprecated-Versionen mit x-deprecated: true markieren.
7Was ist eine Evergreen API?
API ohne Versionierung, die nur Non-Breaking Changes vornimmt. Funktioniert nur mit strikter Backward-Compatibility-Disziplin und kontrollierten Consumer-Ökosystemen.
8Wie vermeide ich Logik-Duplizierung?
Business-Logik in Services, nicht Controller. Versions-Unterschiede nur in DTOs und Serializer-Gruppen. Ein Service, mehrere Response-Formate.
9Wie kommuniziere ich Deprecation?
Sunset-Header, Deprecation-Header, Link-Header mit rel='deprecation'. Plus E-Mail an registrierte Consumer und Changelog-Eintrag.
10Versionierung nachträglich einführen?
Ja, mit Aufwand. Bestehende Routen als v1 deklarieren, Sunset-Datum kommunizieren. Neue Features nur in v2. Bestehende Clients haben Zeit zu migrieren.