Versioning and Documentation

Why a public API needs versions, which changes break a client, and how to keep the documentation generated instead of written.

Syntax#[Route('/api/v1/articles', name: 'api_v1_articles_')]

A public API is consumed by code you cannot change. Once someone else's release depends on your response shape, you need a way to change that shape without breaking them. That is all a version is: a promise that this URL's contract holds until you say otherwise.

What counts as breaking

Removing a field. Renaming one. Making an optional input required. Narrowing a type, including a numeric string to an integer. Returning a different status code for the same outcome. Changing the default sort order or page size. Tightening validation. Every one of those breaks a client that was working yesterday.

Usually safe: adding an optional input, adding a field, adding an operation. Usually, because a client that validates responses against a strict schema breaks on a new field too — ask your consumers which they do.

Where the version lives

A path prefix is the plainest choice: /api/v1/articles. A class-level #[Route] gives every method in that controller the path prefix and a route-name prefix, and prefix with name_prefix in the routes import does the same for a whole directory. One version per namespace, so the old code stops changing when the new one starts. Announce an end date with a Sunset header (RFC 8594) rather than a blog post.

Generated, not written

Hand-written documentation drifts within a sprint. Generate the specification from the code, commit it, and let the build fail when the committed file no longer matches. That diff is your breaking-change review.

Example

Example · php
// src/Controller/Api/V1/ArticleController.php
declare(strict_types=1);

namespace App\Controller\Api\V1;

use App\Repository\ArticleRepository;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route;

// Shorthand: the class-level attribute prefixes the path of every route in
// the class and prefixes their names, so show() is /api/v1/articles/{id}
// and its route name is api_v1_articles_show.
#[Route('/api/v1/articles', name: 'api_v1_articles_')]
final class ArticleController extends AbstractController
{
    public function __construct(private readonly ArticleRepository $articles)
    {
    }

    #[Route('/{id}', name: 'show', requirements: ['id' => '\d+'], methods: ['GET'])]
    public function show(int $id): JsonResponse
    {
        $article = $this->articles->find($id) ?? throw $this->createNotFoundException();

        // The v1 read group is frozen. New fields go into the v2 group.
        $response = $this->json($article, Response::HTTP_OK, [], [
            'groups' => ['article:read:v1'],
        ]);

        // RFC 8594. Tells clients the date this version stops answering.
        $response->headers->set('Sunset', 'Wed, 01 Jul 2026 00:00:00 GMT');

        return $response;
    }
}

// GET /api/v1/articles/7 returns 200 with the v1 field set and a Sunset
// header. GET /api/v2/articles/7 is a different controller with its own
// group, and neither file changes when the other does.

When to use it

  • Rename a published field without breaking the mobile app that is still shipping the old build.
  • Give integrators a dated Sunset header instead of an email when an endpoint is retiring.
  • Run two API versions side by side from one set of entities during a migration window.
  • Make a build fail when a change to a serialization group alters the published contract.

More examples

The same prefix, set in the routing import

This is the longhand of the class-level <code>#[Route]</code> prefix, and it produces the same paths and route names. Reach for it when the version applies to a directory rather than one class: controllers inside <code>Api/V1/</code> then carry no version in their own attributes, which means copying the directory to start v2 needs no edits inside the files. Check the result with <code>debug:router</code> before you rely on it, since a prefix applied twice is easy to miss.

Example · yaml
# config/routes.yaml
api_v1:
    resource: '../src/Controller/Api/V1/'
    type: attribute
    # Added to the start of every imported route path
    prefix: '/api/v1'
    # Added to the start of every imported route name
    name_prefix: 'api_v1_'

api_v2:
    resource: '../src/Controller/Api/V2/'
    type: attribute
    prefix: '/api/v2'
    name_prefix: 'api_v2_'

Failing the build on an undeclared change

The point is not the file, it is the gate. Someone removes a serialization group, the regenerated document loses a field, the build goes red, and a human decides whether that belongs in a new version. Reviewing the spec diff is far easier than reviewing attribute changes scattered across entities, and the committed document doubles as the artefact you hand to client developers.

Example · bash
# CI step: regenerate the committed specification and compare
php bin/console api:openapi:export --yaml --output=docs/openapi.yaml
git diff --exit-code docs/openapi.yaml

# git diff --exit-code returns 1 when the file changed, so the build fails
# whenever the code describes a different API from the committed document.

Two versions, one property

Renaming a field is a breaking change, and this is how you make it without a second table or a second entity. The old name survives as a getter in the frozen v1 group; the property carries the new name in v2. When v1 reaches its sunset date you delete the getter and its group, and nothing else moves. The cost is real — every field that diverges adds a method — which is the argument for keeping versions few and retirement dates short.

Example · php
// src/Entity/Article.php
declare(strict_types=1);

namespace App\Entity;

use Doctrine\ORM\Mapping as ORM;
use Symfony\Component\Serializer\Attribute\Groups;
use Symfony\Component\Serializer\Attribute\SerializedName;

#[ORM\Entity]
class Article
{
    #[ORM\Id]
    #[ORM\GeneratedValue]
    #[ORM\Column]
    #[Groups(['article:read:v1', 'article:read:v2'])]
    private ?int $id = null;

    // v2 publishes this property under its own name.
    #[ORM\Column(length: 180)]
    #[Groups(['article:read:v2'])]
    public string $title = '';

    // v1 published the same value as "name" and must keep doing so until
    // the sunset date. One stored column, two wire names.
    #[Groups(['article:read:v1'])]
    #[SerializedName('name')]
    public function getName(): string
    {
        return $this->title;
    }

    public function getId(): ?int
    {
        return $this->id;
    }
}

Discussion

  • Be the first to comment on this lesson.