REST API Versioning Without Chaos
AI generated
{ }
GET
REST API · Versioning · Symfony · OpenAPI · Backward Compatibility
REST API Versioning Without Chaos
URL, Header, Sunset and Backward Compatibility

Unplanned API versioning ends with /v1, /v2, /v2-new, /v3-beta and a team that no longer knows which clients still use which version. Clear versioning strategies, Sunset headers and backward compatibility patterns keep versions from turning into a maintenance problem.

14 min read URL Versioning · Header Versioning · Sunset Header · Symfony Routing · OpenAPI Symfony 6.x / 7.x · PHP 8.2+

1. Why API versioning is a strategic problem

Most teams start without an explicit versioning strategy. When the first breaking change arrives, a renamed field, a changed response structure, a removed property, the first ad hoc decision gets made: append the prefix /v2 to all routes and move on. Six months later /v1 and /v2 exist in parallel, the business logic is duplicated between both versions, and nobody knows which clients still hit /v1.

The real problem is not versioning itself but the lack of a clear deprecation strategy and a defined lifecycle model. When is a version marked as deprecated? How long is it still supported? Who informs the API consumers? Without answers to these questions, the number of versions running in parallel grows with every breaking change, until the maintenance effort outweighs feature development.

2. URL versioning: /v1, /v2, pros and cons

URL versioning is the most commonly used strategy: the version prefix sits in the path, e.g. /api/v1/orders and /api/v2/orders. The biggest advantage is visibility: the version is immediately recognizable in every request log, every monitoring dashboard and every browser address bar. Developers can have both versions open in browser tabs at the same time, filter logs by version, and configure caching systems easily per version.

The drawbacks are just as real: technically speaking, URL versioning violates the REST principle that a URI identifies a single unique resource, /v1/orders/42 and /v2/orders/42 are the same order, just in different representations. In practice that is an academic argument that barely matters. More serious is that URL versioning forces clients to change all base URLs in their configuration when upgrading versions, a real migration effort, especially for mobile apps with a long release cycle.


<?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 versioning: Accept and API-Version headers

Header-based versioning keeps the URL clean: GET /api/orders/42 stays the same URL, the version is communicated via a header. Two common variants: the Accept header with media type versioning (Accept: application/vnd.mironsoft.v2+json) follows the HTTP standard strictly and uses content negotiation. The more pragmatic custom header Api-Version: 2 is easier to debug and log but is not part of the HTTP standard.

The most important drawback of header versioning: it is less visible to developers. In browser address bars, API logs without header output and monitoring dashboards, you do not see the version right away. Debugging issues where client A calls version 1 and client B calls version 2 becomes more effort. For internal APIs between teams within the same company, header versioning is often the better choice. For public APIs with many different clients, the advantages of URL versioning outweigh it.

4. Symfony routing for multiple API versions

Symfony offers several approaches to routing multiple API versions. The cleanest for URL versioning: separate controller namespaces (App\Controller\Api\V1, App\Controller\Api\V2) with shared services and DTOs that differ only in their serialization. The business logic lives in services, not controllers, since version differences almost always only affect the request/response format, not the domain logic.

For header versioning, a request attribute listener is a good fit, one that reads the requested version from the header and sets it as a request attribute. Routing can then react to the header via condition expressions, or an event listener internally forwards the request to the correct handler.


# 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: what a breaking change means

A breaking change in a REST API is any change that breaks existing clients without a code adjustment. Classic breaking changes: removing a required field from a response, renaming a field (customer_name to customer.name), changing a data type (string to integer), changing the HTTP status (200 to 201), changing the URL structure. Everything else counts as a non-breaking change: adding optional fields, new optional request parameters, new endpoints, new HTTP methods on existing routes.

The most important rule: adding is almost always safe, removing and renaming is always a breaking change. Clients must be able to ignore additional fields (the Robustness Principle). That means: no strict schema validation on the client side, and API schemas must allow additionalProperties: true. Anyone who consistently follows this rule can implement many planned breaking changes as non-breaking changes by keeping the old field alongside the new one.


// Non-Breaking Change: add a new field
// V1 response (still valid):
{
  "id": 42,
  "customer_name": "Max Mustermann",
  "total": 149.99
}

// V1 response after non-breaking change (old field stays):
{
  "id": 42,
  "customer_name": "Max Mustermann",
  "customer": {
    "name": "Max Mustermann",
    "email": "max@example.com"
  },
  "total": 149.99
}

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

// Strategy: ship both fields in V1, communicate the sunset date,
// remove the old field only in V2. This gives clients time to migrate.

6. Sunset headers and deprecation communication

The Sunset header (RFC 8594) is the tool for informing API consumers about upcoming shutdowns. It contains an HTTP date after which the endpoint or version will no longer be available. Together with the Deprecation header (RFC 9745) and a Link header with rel="successor-version", clients have all the information they need for a migration, machine-readable, without depending on documentation.

The sunset date should factor in a realistic migration window: at least 6 months for internal APIs, 12 months or more for public APIs with an unknown client ecosystem. An event listener that automatically sets the Sunset header for all requests against deprecated routes prevents individual endpoints from being forgotten. Monitoring tools like Prometheus can react to the Sunset header and raise alerts before the shutdown date is reached.


<?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. Documenting multiple API versions in OpenAPI

The most common practice is a dedicated OpenAPI file per version: openapi-v1.yaml, openapi-v2.yaml. This is maintainable and avoids hiding version differences inside one giant specification. Shared schemas (e.g. ProblemDetails, Pagination) are managed in a separate file and referenced in both versions via $ref.

Swagger UI and Redoc support version switchers: either via separate URLs (/api-docs/v1, /api-docs/v2) or a dropdown in the UI. The OpenAPI file of the deprecated version should carry an x-deprecated: true extension field and a note in info.description with the sunset date and a link to the successor version.

8. Versioning strategies compared

No versioning strategy is universally correct. The choice depends on the type of API (internal / public), the client ecosystem, and the backward compatibility requirements.

Strategy Visibility Cache-friendly REST-compliant Recommendation
URL prefix (/v1, /v2) Very high Yes Debated Public APIs
Custom header (Api-Version) Medium Only with Vary Pragmatic Internal APIs
Accept header (vnd. media types) Low With Vary High REST purists
Query parameter (?version=2) High Yes Low Avoid
No versioning (evergreen API) - Yes Yes Only with strict backward compat.

Mironsoft

REST API design, Symfony backend development and OpenAPI documentation

Want a strategic setup for API versioning?

We analyze your existing API structure, define a clear versioning strategy and implement Sunset headers, deprecation monitoring and migration guides for orderly version transitions.

Strategy review

Analysis of existing version structures and a recommendation for the right strategy

Implementation

Symfony routing, deprecation listener and automatic Sunset headers

OpenAPI docs

Separate OpenAPI files per version with migration guides and deprecation notes

9. Summary

REST API versioning without chaos requires three decisions before the first breaking change: which strategy (URL vs. header), which lifecycle model (how long are versions supported), and how are clients informed about deprecation. URL versioning is the pragmatically best choice for most public APIs, it is visible, debuggable and cache-friendly. Header versioning suits internal APIs where all clients are controlled.

Sunset headers communicate shutdown dates in a machine-readable way. Backward compatibility analysis before every API change prevents unnecessary breaking changes. And a clear separation: business logic in services, version differences only in controllers and DTOs. That keeps the codebase maintainable, even while V1 and V2 run in parallel.

API Versioning Without Chaos, the Essentials at a Glance

Fix the strategy early

URL versioning for public APIs (/v1, /v2). Header versioning for internal APIs. Always avoid query parameters.

Breaking vs. non-breaking

Adding is safe, removing and renaming is breaking. Ship old fields alongside new ones, communicate the sunset date, then remove them in V2.

Sunset header (RFC 8594)

Machine-readable shutdown date. Combined with the Deprecation header and Link: rel="successor-version" for automated client alerts.

OpenAPI per version

Separate openapi-v1.yaml, openapi-v2.yaml. Shared schemas as $ref. Mark the deprecated version with x-deprecated: true.

10. FAQ: REST API Versioning Without Chaos

1URL versioning or header, which is better?
For public APIs, URL versioning (/v1, /v2): visible, debuggable, cache-friendly. Header versioning for internal APIs. Always avoid query parameters.
2What is a breaking change?
Removing fields, renaming, changing data types, changing HTTP status codes. Adding fields is almost always safe, clients need to be able to ignore unknown fields.
3What does the Sunset header do?
RFC 8594: a machine-readable shutdown date. Monitoring tools and API clients can warn automatically before the date is reached.
4How long should a deprecated version stay alive?
Internal APIs: at least 6 months. Public APIs: at least 12 months. Factor in mobile app release cycles.
5Symfony implementation?
Separate controller namespaces, routing via YAML with a prefix, an event listener automatically sets the Sunset header for deprecated versions.
6A dedicated OpenAPI file per version?
Yes. openapi-v1.yaml and openapi-v2.yaml kept separate. Shared schemas embedded via $ref in a third file. Mark deprecated versions with x-deprecated: true.
7What is an evergreen API?
An API without versioning that only makes non-breaking changes. Works only with strict backward compatibility discipline and controlled consumer ecosystems.
8How do I avoid duplicating logic?
Business logic in services, not controllers. Version differences only in DTOs and serializer groups. One service, multiple response formats.
9How do I communicate deprecation?
Sunset header, Deprecation header, Link header with rel='deprecation'. Plus email to registered consumers and a changelog entry.
10Introduce versioning retroactively?
Yes, with effort. Declare existing routes as v1, communicate the sunset date. New features only in v2. Existing clients get time to migrate.