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.
Table of Contents
- 1. Why API versioning is a strategic problem
- 2. URL versioning: /v1, /v2, pros and cons
- 3. Header versioning: Accept and API-Version headers
- 4. Symfony routing for multiple API versions
- 5. Backward compatibility: what a breaking change means
- 6. Sunset headers and deprecation communication
- 7. Documenting multiple API versions in OpenAPI
- 8. Versioning strategies compared
- 9. Summary
- 10. FAQ
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.