Versioning and Documentation
Why a public API needs versions, which changes break a client, and how to keep the documentation generated instead of written.
#[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
// 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.
# 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.
# 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.
// 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