Symfony API Versioning: Strategies Without Breaking Changes
AI generated
SF
{ }
Symfony 7 · API Versioning · REST · Breaking Changes
Symfony API Versioning
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.

17 min read URI Versioning · Header Versioning · Content Negotiation · Deprecation Symfony 7.x · API Platform 4 · PHP 8.4

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

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.

11. FAQ: API Versioning in Symfony

1What is API versioning?
Mechanism that allows API evolution without breaking clients. Decouples server development from client updates through version numbers and deprecation communication.
2URI vs. header versioning?
URI (/api/v1/): visible, simple, developer friendly. Header (Accept: application/vnd.api.v2+json): more REST compliant, more complex clients. URI recommended for public APIs.
3Implementing URI versioning in Symfony?
Separate controller namespaces per version with #[Route('/api/v1/...')] or routing resources with prefix. Share services and business logic between versions.
4What is evolutionary API design?
Only add fields, never rename or remove them. New fields optional with defaults. Clients ignore unknown fields, reducing the need for explicit versioning.
5Symfony serializer groups for versioning?
#[Groups(['product:read:v1'])] for v1-exclusive fields. Context builder activates the correct group based on the api_version request attribute. No code duplication.
6What is the deprecation header?
RFC 8594: Deprecation: "2026-01-01" communicates deprecation. Sunset: "2027-01-01" communicates the shutdown date. Response subscriber adds the header automatically on deprecated versions.
7Versioning with API Platform 4?
Separate output DTOs per version, state provider transforms the entity. uriTemplate parameter for version paths. OpenAPI docs generated automatically for both versions.
8When to introduce a new API version?
For real breaking changes: renaming/removing fields, changing data types, changing endpoint structure. New optional fields are not a reason, build them in evolutionarily.
9How long to keep running a deprecated API version?
At least 6-12 months after the announcement. Sunset header communicates the date. Log monitoring by version, shut down only once there is no more traffic on the old version.
10API versioning without code duplication?
Yes, Symfony serializer groups control field selection per version declaratively. Business logic stays in shared services. Only the presentation layer (DTOs or groups) differs between versions.