Strategies Without Breaking Changes
Every public API has to change eventually. The question is not whether, but how, without breaking existing clients. API versioning is not a pure architecture decision, it is an operational process: from the URI strategy through content negotiation to structured deprecation cycles, the strategy determines how much technical debt an API accumulates over time.
Table of Contents
- 1. Why API Versioning Is Necessary
- 2. URI Versioning: /api/v1/ vs. /api/v2/
- 3. Header Versioning: Accept and Custom Headers
- 4. Implementing Content Negotiation in Symfony
- 5. Evolutionary API: Extension Without Breaking Changes
- 6. Version-Dependent Serialization With Symfony Groups
- 7. Deprecation Strategy: Communicating Old Versions
- 8. API Versioning With API Platform 4
- 9. Versioning Strategies Compared
- 10. Summary
- 11. FAQ
1. Why API Versioning Is Necessary
An API is a contract between the server and its clients. If you change that contract unilaterally, renaming a field, changing a data type, removing an endpoint, every client relying on the old behavior breaks. In internal projects you can know all the clients and migrate them in a coordinated way. For public APIs, partner APIs or mobile applications that users do not update immediately, uncoordinated change is not an option. API versioning is the mechanism that decouples server development from client updates.
The core problem is that breaking changes in REST APIs are hard to avoid once an API grows. Fields get renamed because the original semantics were unclear. Data types change because an integer ID migrates to a string UUID. Endpoints get split because a resource became too complex. Without API versioning, these changes accumulate into technical debt that eventually becomes unmaintainable. With well thought out API versioning, each version stays stable, and clients migrate on their own initiative, on a communicated deprecation schedule.
2. URI Versioning: /api/v1/ vs. /api/v2/
URI versioning is the best known and most commonly used API versioning strategy: the version identifier is part of the path, for example /api/v1/products and /api/v2/products. The advantage is obvious: the version is visible in every request, easy to identify in logs and usable without any special client configuration. Browsers and simple HTTP clients can address both versions simultaneously. For public APIs, this is by far the most developer-friendly strategy, no header magic, no content type parsing.
In Symfony, URI versioning is implemented through routing: separate controllers per version, or a shared controller with a version routing prefix. The cleaner variant is separate controllers that each draw their business logic from shared services. That avoids code duplication at the logic layer and keeps controllers lean. A version routing prefix in routes/api.yaml bundles all v1 routes under a prefix without having to adjust each controller individually. Symfony's routing system allows this bundling via prefix: /api/v1 in the routing resource configuration.
<?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
3. Header Versioning: Accept and Custom Headers
Header-based API versioning keeps the URI clean and moves the version indication into the HTTP Accept header or a custom header. This matches the REST philosophy more closely than URI versioning, because a resource has a single URI, regardless of the format it is returned in. In practice, the Accept header then looks like this: Accept: application/vnd.myapi.v2+json. The vendor MIME type encodes the version and the format. Symfony can read the header in a content negotiation listener and steer the processing logic accordingly.
The challenge with header versioning lies in the client implementation: every HTTP client must explicitly set the correct Accept header. Browser forms, simple curl calls and tools that use plain HTTP standard headers get the default version. That is acceptable in some projects, in others it leads to subtle bugs when clients forget to set the header and unexpectedly land on an old version. Custom headers such as X-API-Version: 2 are simpler to implement, but are not an HTTP standard and get stripped by some proxies.
4. Implementing Content Negotiation in Symfony
Symfony offers a built-in mechanism for content negotiation via the Request object: $request->getPreferredFormat() analyzes the Accept header and returns the preferred format. For API versioning via Accept header, you implement a kernel event listener that analyzes the Accept header for version information and stores the result as a request attribute. Controllers can then access the version via $request->attributes->get('api_version') without parsing the header themselves.
A cleaner pattern for content-negotiation-based API versioning uses Symfony's FormatListener from the FOSRestBundle or implements a custom EventSubscriber on KernelEvents::REQUEST. The subscriber parses the Accept header, extracts the version and sets it as a request attribute. Downstream handlers, controllers, serializer context builders, read this attribute. That keeps the versioning logic in a single place and avoids duplication in every controller. The combination of request attribute and serialization groups makes content negotiation an elegant API versioning strategy for 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. Evolutionary API: Extension Without Breaking Changes
The best API versioning strategy is the one you rarely need, because the API was built for evolution from the start. The principle of the evolutionary API: fields are never renamed or removed, only added. New fields are optional and have sensible defaults. Data types never change, if an ID needs to move from integer to string, a new field uuid is added while the old id field remains an integer. Clients that only know id keep working. New clients can use uuid.
Postel's Law, "be conservative in what you send, be liberal in what you accept", is the fundamental design principle behind evolutionary APIs. On the receiving side: fields the server does not know are ignored (no additionalProperties: false in the JSON Schema). On the output side: always complete, never arbitrarily reduced. If a client sends an unknown optional field, the server does not return a validation error. If the server sends a new optional field, the client does not crash. These two principles together enable independent versioning of server and clients, the goal of every good API versioning strategy.
6. Version-Dependent Serialization With Symfony Groups
Symfony's serializer groups are a powerful tool for API versioning without code duplication. Instead of separate controller classes per version, serialization groups control which fields are visible in which API version. A property with #[Groups(['product:read:v1', 'product:read:v2'])] appears in both versions. A new property with #[Groups(['product:read:v2'])] appears only in v2. A property scheduled for removal keeps its v1 group and loses the v2 group, it becomes invisible to new clients without harming old ones.
The key is a serializer context builder that reads the API version from the request attribute and activates the corresponding serialization group. Combined with the ApiVersionSubscriber from section 4, the entire versioning logic is concentrated in two classes, controllers and entities remain untouched. This is the cleanest approach for API versioning in Symfony: no URL routing sprawl, no duplicate controllers, no manual response transformations. Just serialization groups that declaratively control what is visible in which version.
<?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 Strategy: Communicating Old Versions
An API versioning strategy without deprecation communication is incomplete. Clients need to know when an old version will be shut down, with sufficient lead time. The technical implementation in Symfony: a response subscriber adds a Deprecation header per RFC 8594 to requests hitting outdated API versions. The header contains the date from which the version is considered deprecated, and optionally a link to the sunset date and to migration documentation. Clients that pay attention to these headers can warn automatically.
The Sunset header (RFC 8594) complements the Deprecation header and communicates the exact shutdown date of a version: Sunset: Sat, 1 Jan 2027 00:00:00 GMT. In monitoring tools, you can watch for requests against a deprecated version to disappear, to be confident that all clients have migrated. Server-side access logs aggregated by version give a clear picture of what share of requests still hit old versions. This data informs the decision of when a version can actually be shut down, without flying blind.
8. API Versioning With API Platform 4
API Platform 4 offers several entry points for API versioning. The recommended approach is using separate resource classes per version, no duplicate entity, but separate output DTOs. A ProductOutputV1 and a ProductOutputV2 DTO represent the respective versions. The state provider transforms the entity into the version-specific output DTO. API Platform serializes the DTO and returns it. The controller code stays identical, only the DTO changes between versions.
URI versioning in API Platform 4 is set up via separate API prefixes: api_platform: prefix: /v1 for v1 resources and a second bundle mounting for v2 resources under /v2. Alternatively, you can set the uriTemplate parameter directly on the operation: new GetCollection(uriTemplate: '/v2/products') on a v2 resource. The advantage of this approach: API Platform automatically generates OpenAPI documentation for both versions, the difference between v1 and v2 is immediately visible in the OpenAPI spec. That significantly eases client migration, because developers can compare the changes directly in the Swagger UI.
9. Versioning Strategies Compared
Choosing the right API versioning strategy depends on the project type, the target audience and the team. The table shows the most important tradeoffs.
| Strategy | Advantage | Disadvantage | Suited For |
|---|---|---|---|
| URI /v1/ /v2/ | Visible, simple, debuggable | URI pollution, goes against REST | Public APIs, developer friendly |
| Accept Header | REST compliant, clean URIs | Complex client configuration | Internal APIs, experienced clients |
| Custom Header X-API-Version | Simple client implementation | Not an HTTP standard, proxy risk | Partner APIs, controlled environment |
| Evolutionary API | No breaking change, no versioning | Requires discipline in API design | Long-lived APIs, stable data model |
| Symfony Groups | No code duplication, declarative | Group management gets complex with many versions | Field-level changes, API Platform |
In practice, most successful Symfony APIs combine several strategies: URI versioning for major versions that contain real breaking changes, and evolutionary extension for minor changes within a version. Symfony Groups control fine-grained field selection per version without requiring separate controllers. The deprecation header communicates the shutdown timeline. This combination gives clients maximum stability and the server team the freedom to keep evolving the API.
Mironsoft
Symfony API architecture, versioning strategies and breaking-change-free migration
Want to build or migrate a Symfony API with a clear versioning strategy?
We build Symfony APIs with a well thought out versioning strategy, from the initial architecture decision through evolutionary extension patterns to structured deprecation communication for your clients.
API Architecture
Versioning strategy, evolutionary API design and deprecation planning for your Symfony API
Breaking-Change Migration
Structured migration of existing APIs to new versions without client downtime
API Platform Integration
Versioning in API Platform 4 with output DTOs, serializer groups and automatic OpenAPI docs
10. Summary
API versioning in Symfony is not a single technique, but a set of decisions that together produce a maintainable, client-friendly API. URI versioning (/api/v1/) is the simplest and most developer-friendly option for public APIs. Header versioning via the Accept header is more REST compliant, but requires more client discipline. Evolutionary API design, adding new fields, never removing old ones, reduces the need for explicit versioning to real breaking changes. Symfony serializer groups implement fine-grained field selection per version without controller duplication.
The deprecation strategy is the underrated part of every API versioning effort: without a clear communication plan for sunset dates and migration paths, old versions live on forever because nobody knows whether clients still depend on them. RFC 8594 Deprecation and Sunset headers, combined with access log monitoring by API version, provide the data foundation for shutdown decisions. Good API versioning is technically solvable, the human part is the discipline to stick with the strategy from the start.
Symfony API Versioning, The Essentials at a Glance
URI Versioning
/api/v1/ and /api/v2/, visible, debuggable, developer friendly. Best choice for public APIs. Symfony routing prefix bundles all version routes.
Evolutionary API
Only add fields, never remove or rename. New fields optional with defaults. Postel's Law: liberal in receiving, conservative in sending.
Symfony Groups
#[Groups(['product:read:v1'])] controls field selection per version declaratively. No code duplication, no duplicate controllers, versioning logic in the serializer context.
Deprecation
Deprecation and Sunset headers (RFC 8594) communicate the shutdown date. Access log monitoring by version, shut down only once there is no more traffic.