Skip to content

API versioning

Your API changed shape, and the integrations built against last year’s version still expect the old one. Docuccino publishes a document for every version you still serve — each a complete, standalone OpenAPI document, generated from the code you have today.

You declare each breaking change once, as a small class. Nothing is duplicated, nothing is branched, and no old shape has to be kept compiling.

This is the one rule to get straight, because everything else follows from it and getting it backwards is the classic bug.

A version change describes a shape that is no longer in your code. Your working tree is always the newest version; older versions’ documents are derived from it by applying each later change in reverse. So:

  • Nothing is deleted, and nothing is enacted. You make the change in your code the way you always would, and then write down what the API did before it.
  • The newest version’s document is the plain build, untouched.
  • to: is the field name in your code today. from: is the name older versions publish.

Write that pair the other way round and you rename the wrong end: the older document then describes a shape nobody ever served, and every generated client for it is wrong.

Say invoices used to publish name, and on 2026-09-01 that field became title. You are still serving 2026-06-01 for callers who haven’t moved.

  1. Declare the change. One class, no body, in a directory of its own:

    app/Api/Versions/InvoiceTitleReplacesName.php
    namespace App\Api\Versions;
    use App\Http\Resources\InvoiceResource;
    use Docuccino\Attributes\Versioning\ApiVersionChange;
    use Docuccino\Attributes\Versioning\RenamedResponseField;
    #[ApiVersionChange(
    since: '2026-09-01',
    description: 'Invoices publish `title` where they used to publish `name`.',
    )]
    #[RenamedResponseField(schema: InvoiceResource::class, from: 'name', to: 'title')]
    final class InvoiceTitleReplacesName {}

    since is the first version whose document carries the new shape. description is written for a consumer deciding whether the upgrade touches them, so it goes in the document — say what changed, not which attribute you used.

    The class body is never read and never called. Every argument is a plain string or a ::class constant — the constructors accept nothing else — which is what lets Docuccino compile the declaration without running your app.

  2. Configure a document per version in docuccino.yaml. Each one is an ordinary document with an api_version block:

    documents:
    v2026-09-01:
    info: { title: 'Billing API', version: '2026-09-01' }
    routes: { include: ['api/*'] }
    versioning: 'date'
    api_version: { changes: ['app/Api/Versions'] }
    export: { path: 'docs/2026-09-01.openapi.json' }
    v2026-06-01:
    info: { title: 'Billing API', version: '2026-06-01' }
    routes: { include: ['api/*'] }
    versioning: 'date'
    api_version: { changes: ['app/Api/Versions'] }
    export: { path: 'docs/2026-06-01.openapi.json' }

    Both documents read the same directory of changes. What separates them is info.version: each document applies the changes that shipped after its own version, so the newer one applies none and the older one applies the rename.

  3. Export. Two commands’ worth of nothing new — every command already takes a document key, and omitting it runs all of them:

    Terminal window
    php artisan docuccino:export
  4. Assert the wire against each version. Pin a version, replay a request, and require the response to validate against that version’s document:

    use Docuccino\Laravel\Testing\ApiContract;
    it('serves the 2026-06-01 shape when 2026-06-01 is pinned', function (): void {
    ApiContract::forDocument('v2026-06-01');
    $response = $this->withHeader('X-Api-Version', '2026-06-01')
    ->getJson('api/invoices')
    ->assertOk();
    $this->assertValidResponse($response);
    });

    Go through the HTTP kernel — getJson(), not a hand-built response — so your router and your middleware really run. That is the entire point of the check.

You do not have to write step 1 by hand. Commit the artifact for a version when you release it — the document is byte-deterministic precisely so that it can be committed and diffed — and when you cut the next one, scaffold the classes from the difference:

Terminal window
php artisan docuccino:version-changes docs/2026-06-01.uir.json v2026-09-01 --since=2026-09-01

It reads the committed document, builds the current one, diffs the two over the same stable identities docuccino:diff uses, and writes a class for every difference the vocabulary expresses — the target, the shape and the required-ness all filled in, and the description already carrying the factual sentence:

#[ApiVersionChange(
since: '2026-09-01',
description: '`InvoiceResource` publishes `title` where it published `name`.',
)]
#[RenamedResponseField(schema: InvoiceResource::class, from: 'name', to: 'title')]
final class InvoiceResourceTitleReplacesName {}

What is left is the half only you know: why it changed, and whom it affects. That is the sentence a consumer reads when they are deciding whether the upgrade touches them, so it is worth the minute.

It scopes a change with #[AppliesTo] only when the change really is partial — when one of the operations publishing the schema already published today’s shape in the older version, because your code pointed it somewhere else then. A component that simply changed changed for every operation referencing it, and a scope there would fork the ones it named and leave the rest alone, which is the opposite of what it says.

Each class is written beside the module that owns it. If your changes entry contains a glob, the wildcard is where you declared your boundary — modules/*/Api/Versions says a module is the unit — so a change to a Billing resource lands in Billing:

Written
InvoiceResourceTitleReplacesName — `InvoiceResource` publishes `title` where it published `name`.
into modules/Billing/Api/Versions — beside modules/Billing, which owns Billing\Data\InvoiceResource.

The destination and the reason are printed for every class, found module or not, and --in overrides the lot. Nothing is guessed: with a single literal directory configured there is no boundary to read and everything goes there, exactly as before.

A renamed parameter is scoped the same way and for the same reason — the operations the diff showed it on, unless that is every operation the head declares it for — and it names no class, so it goes to the first configured directory with the reason saying that a parameter has no module to be owned by.

Two things it will not do. It never touches a class that already exists — the sentence you wrote in one is the whole value of it. And it writes nothing for a difference no verb expresses; those are printed instead, so an incomplete version is one you can see rather than one you find out about later. A parameter that went and nothing arrived to pair with, two departures one arrival could stand for, a parameter that arrived where an older version simply had none — each is a printed sentence rather than a guess. --against=<git-ref> reads the old artifact straight out of git if you would rather not check one out, and --dry-run prints the plan without writing. Full flags, and how to customise the generated class, are in the commands reference.

The two documents differ in the field name, in info.version, and in nothing else you didn’t ask for:

// components.schemas.Invoice — the shape your code publishes today
{
"type": "object",
"properties": {
"id": { "type": "integer" },
"title": { "type": "string" },
"total": { "type": "integer" }
},
"required": ["id", "title"]
}

The property keeps its position and everything else it carried — its description, its example — and required is rewritten along with it. A required still naming today’s field would accept exactly the bodies the document says are invalid, which is the disagreement the contract test exists to catch.

Every operation also gains the header a client pins a version with. It is declared once, under components.parameters, and every operation points at it:

{
"components": {
"parameters": {
"XApiVersion": {
"name": "X-Api-Version",
"in": "header",
"required": false,
"description": "The API version this request is answered as. Omit it and the request is answered as the version this document describes.",
"schema": {
"type": "string",
"enum": ["2026-06-01", "2026-09-01"],
"default": "2026-06-01",
"x-enum-varnames": ["V2026_06_01", "V2026_09_01"],
"x-enumNames": ["V2026_06_01", "V2026_09_01"],
"x-enum-descriptions": ["", "Invoices publish `title` where they used to publish `name`."]
}
}
}
},
"paths": {
"/api/invoices": {
"get": {
"parameters": [{ "$ref": "#/components/parameters/XApiVersion" }]
}
}
}
}

The component is named after the header, so renaming the header renames it and nothing else can move it. Restated on every operation it would be the largest repeated thing in the document — four arrays one member long per version, on every endpoint — and a 400-endpoint API at fifty versions would publish several megabytes of one sentence. Shared, the size of the declaration no longer depends on how many endpoints or how many versions you have.

The enum is the closed set of versions your documents map declares, so a consumer reads the supported versions out of the document instead of out of your changelog, and a generated client gets a named constant per version — a date is not an identifier, so the names that make it one travel beside it. Each change’s own description becomes that version’s entry.

Rename the header with api_version.header if yours is called something else. Document the header yourself with #[HeaderParameter] on an operation and Docuccino leaves your wording alone — two parameters of one name in one place is a document no client can read.

A change carries one or more verbs. Most name a class and a field; one names a parameter, which belongs to no class. There are seven:

Verb Says The older document
#[RenamedResponseField] this response field used to be called something else publishes it under the old name
#[RenamedRequestField] this request field used to be called something else accepts it under the old name
#[RenamedParameter] this query, header or cookie parameter used to be called something else documents it under the old name
#[MadeResponseFieldRequired] this response field is now always sent leaves it out of required
#[MadeResponseFieldOptional] this response field can now be absent names it in required
#[MadeRequestFieldOptional] this request field is now optional names it in required, and refuses a body without it
#[RemovedResponseField] this response field is gone publishes it again, with the shape you declare

Every one but the removal names its field or parameter the way your code spells it today — including each rename’s to:. That is the direction the whole vocabulary runs in, and it is the one thing to keep straight.

The removal is the exception, and it has to be: the field is not in your code, so there is no present tense to name it in. field: is what the older versions published, and type: is its shape — the one fact no amount of reading your code can recover, because it was deleted along with the field.

Required-ness is two verbs per side of the wire rather than one with a flag, and the missing fourth combination is deliberate: a required entry arriving narrows a request and moves nothing on a response, so “made required” means two different things to a consumer depending on which way the field travels. There is no #[MadeRequestFieldRequired].

You can stack verbs of different kinds on one change, and they apply in a fixed order — everything else first, renames last, so a verb naming title still finds title rather than the name the rename just put back, and a field a removal puts back lands where the schema your code publishes says rather than among names the rename invented.

#[ApiVersionChange(
since: '2026-09-01',
description: 'An invoice publishes `title`, and omits `settledAt` until it settles.',
)]
#[MadeResponseFieldOptional(schema: InvoiceResource::class, field: 'settledAt')]
#[RenamedResponseField(schema: InvoiceResource::class, from: 'name', to: 'title')]
final class InvoiceShapeChanged {}

A request verb names the class your request body is recovered from, and reaches only that shape. Where one class produces both — a Data class used on the way in and on the way out — the document publishes two schemas for it, and the request verbs and the response verbs address them separately. A field renamed on both sides therefore takes one declaration per side.

#[RenamedParameter] is the one verb that names no class, because there is no class to name: a parameter stands on the operation rather than in a body, so where it travels and what it is called is all there is. in: is one of query, header or cookie — anything else names no location OpenAPI has and is refused, rather than widened to “wherever a parameter of that name turns up”, which would move a page in the path when you meant the one in the query.

A path parameter is the one location this cannot move, and in: 'path' is refused with a reason. Its name is written twice — on the parameter and again as the {expression} of the path it stands under — and a change can address only the first, so moving it would publish an expression naming no parameter beside a parameter naming no expression. Nothing is lost by refusing it: a client sends /invoices/42 and no name travels with it, so no older version ever accepted a different one. Where the URL itself changed you have an older route, and the honest way to describe one is to keep serving it.

#[ApiVersionChange(
since: '2026-09-01',
description: 'The invoice list takes `search` where it took `q`.',
)]
#[RenamedParameter(in: 'query', from: 'q', to: 'search')]
final class InvoiceSearchReplacesQ {}

Most of them have a runtime half you must write, and one does not:

  • #[MadeResponseFieldOptional] and #[RenamedResponseField] make the older document say something stricter or different, so your runtime has to serve it — and the contract test below will fail if it doesn’t.
  • #[RenamedRequestField] and #[RenamedParameter] are the same claim on the way in: the older document now says a request spelling the field or the parameter the old way is valid, and that is only true if your application still accepts it. It is the half that costs the most when it is wrong — a client pinned to that version is turned away rather than merely misinformed. Replay a request written the old way with the version pinned, and assert the status you expected beside the contract assertions; the section on the contract test below says why that status assertion is the half that goes red.
  • #[MadeResponseFieldRequired] only ever drops a required entry, which widens what the older document accepts. Nothing can serve a response that violates it, so no contract test can falsify it: it is safe by construction rather than by being checked.
  • #[MadeRequestFieldOptional] makes the older document demand a field. That one is falsifiable from the other end: a request replayed at that version without the field is refused, correctly.
  • #[RemovedResponseField] puts a field back. Declared required: true it is falsifiable the same way #[MadeResponseFieldOptional] is — replay the suite pinned to that version and the assertion says whether your runtime really still sends the field. Declared without it, the older document is looser than the wire and nothing can refuse it.

A removal is the only verb that declares a shape, so type: is worth a paragraph. It is read three ways, in this order:

#[ApiVersionChange(
since: '2026-09-01',
description: 'Invoices no longer publish `reviewer` or `subtotal`.',
)]
// 1. A class this document already publishes: the older field points at that component.
#[RemovedResponseField(schema: InvoiceResource::class, field: 'reviewer', type: UserResource::class)]
// 2. One of OpenAPI's own type names, optionally `[]` for a list and `?` for nullable.
#[RemovedResponseField(schema: InvoiceResource::class, field: 'subtotal', type: 'integer', required: true)]
final class InvoiceFieldsRemoved {}

The first reading is the one to reach for. Deriving a version rewrites the whole document, so the component the pointer names carries that version’s shape rather than today’s — a field re-added as a UserResource in the 2026-01-01 document holds the 2026-01-01 user. It also keeps a name a generated client can use, which an inline shape cannot.

Anything type: names that is neither of those publishes the field with no constraints and reports versioning.type-unresolved. That is the degraded answer rather than a refusal — a vague schema is still true — and leaving type: out asks for the same shape deliberately, with nothing said about it.

One consequence of required: true is worth expecting: no example in your document carries a field your code does not have, so an example standing where that schema governs now fails its own schema and is dropped, with versioning.example-dropped naming where it stood. A consumer copies an example and sends it back, so publishing none beats publishing one the document itself refuses.

Deriving a document is a walk down an ordered list of changes, so the order is load-bearing rather than incidental. versioning says which grammar your versions are written in — date for 2026-09-01, semver for 1.4.0 — and it is the same key that tells docuccino:diff --enforce what a breaking change requires.

Say nothing and Docuccino reads the grammar off the versions themselves, so an application writing plain dates or plain semver never writes it down twice. Versions that are neither, or a mixture of both, can’t be ordered: no change is applied and the build tells you so with versioning.unordered.

Ordering is never string comparison. Byte for byte 1.10.0 sorts before 1.9.0, which would apply a semver application’s changes backwards — deterministically, and silently.

Docuccino describes the versions. Your application serves them. In its simplest form that is a middleware that reads the pinned version off the header and walks the response body back through every change that shipped after it, newest first:

final class DowngradeToPinnedApiVersion
{
public function handle(Request $request, Closure $next): Response
{
$response = $next($request);
$pinned = $request->header('X-Api-Version');
if (! is_string($pinned) || ! $response instanceof JsonResponse) {
return $response;
}
// An error envelope is not the resource's shape, and a 204 has no body to rewrite.
if ($response->getStatusCode() >= 400 || $response->getStatusCode() === 204) {
return $response;
}
foreach ($this->changes as $change) {
// Strictly newer than the pin: a caller pinned to the version a change shipped in is
// asking for that change, so it must not fire.
if ($change->since() > $pinned) {
$response->setData($change->downgrade($response->getData(true)));
}
}
return $response;
}
}

Two of those guards are production failure modes people hit for real — a migration applied to an error response, and a migration applied to a 204 No Content. Two more worth knowing about: migrations bleeding into webhook payloads, and a migration that silently stops applying when a route is renamed.

Whatever shape your runtime takes, the declarative half above and the imperative half here are two descriptions of one change, and they can drift apart without anything failing. Which is why the last step of the loop isn’t optional.

What the contract test proves, and what it doesn’t

Section titled “What the contract test proves, and what it doesn’t”

Pin a version, replay your suite, and every response must validate against that version’s document. That’s it — and it is the check the whole approach rests on. Without it you have a document claiming one thing and a runtime doing another, with nothing in between.

It has to be able to fail, so make sure it does: send a response at today’s shape and assert it against the older version’s document. The assertion should refuse it and name the missing field. A per-version check that cannot fail is decoration.

By default a change applies wherever the schema it names is published, which is what you want when a shape changed and it changed everywhere. #[AppliesTo] narrows it:

#[ApiVersionChange(since: '2026-09-01', description: 'The invoice list publishes `title`.')]
#[AppliesTo('GET /api/invoices')]
#[RenamedResponseField(schema: InvoiceResource::class, from: 'name', to: 'title')]
final class InvoiceListTitleReplacesName {}

Name an operation as the document names it — a signature, an operationId, or either with a *.

On a #[RenamedParameter] it is a plain filter and nothing else: a parameter belongs to one operation already, so there is no shared shape to fork and no scope that covers “all of them” to be the no-fork branch. It narrows which operations are visited, and the rest of this section is about the schema verbs.

If that schema is published as a shared component and your scope covers only some of the operations that publish it, then in that version those operations genuinely have a different type from the rest. The older shape is written inline at each of them and the shared component is left as your code has it. No new component name appears: a component name becomes a type name in somebody’s generated client, and it must never depend on how many endpoints happened to share a body. The cost is that each of those operations carries its own copy of the shape, so a client gets an anonymous type per operation rather than one it can name.

Scope the change to every operation that publishes the schema and there’s no fork at all — the component is renamed in place, byte for byte what writing no #[AppliesTo] produces. Which is why docuccino:version-changes writes one only where your application forked the shape: anywhere else the scope is either the same fact said twice, or a fork nothing asked for.

That trade is worth weighing twice for a required-ness change, where the fork buys the least: the two schemas differ by one entry in one list, and each scoped operation pays for the difference with a whole inlined copy of the shape — plus an inlined copy of everything between the operation and it, because every $ref on the way down is expanded too. If the field’s promise really did move everywhere, drop the #[AppliesTo] and take the no-fork branch.

One shape can’t be forked: a schema that leads back to itself. Its private copy would still point at the shared component one level down, so the operation would publish the older shape at the top and today’s inside it. That operation is left at the shape your code publishes, and the build says so. A #[RemovedResponseField] can reach the same wall from the other direction, by putting back a field typed as the schema it is editing; a pointer at anything else stays a pointer, and stays a name the client can use.

Migrating stored records is a different problem, and API versioning does not solve it. If a column changed meaning rather than name, or a value was split in two, no amount of describing your API will reconcile the rows already in your database. Everything on this page is about the shape on the wire.

Two more limits worth knowing before you plan around them:

  • There is no removal verb for an operation. Putting a whole endpoint back means declaring its parameters, bodies, responses and security, and an operation with no documented responses is not vague — it is broken. A field is different, which is why #[RemovedResponseField] exists: one type, and a vague-but-true fallback when it can’t be read.
  • A change that alters behavior rather than shape — different defaults, a different sort order — has no declarative form. Document it in prose and put the sentence in the change’s description, where the consumer will actually read it.

Every way a declared change can fail to land is a warning rather than a silent no-op, because a change that quietly stops applying is indistinguishable from one that was never written. The ones you’ll meet:

Code Usually means
versioning.change-target-missing The field was renamed again, or removed, and the change still names the old spelling
versioning.change-target-unchanged A required-ness change says a field’s promise moved, or a removal names a field your code still publishes, and your code already says what the older version would — usually the direction read backwards
versioning.type-unresolved A removal’s type: is neither a class this document publishes nor an OpenAPI type name, so the field went back unconstrained
versioning.example-dropped An example beside that schema couldn’t be given the shape this version publishes
versioning.schema-unresolved The class you named isn’t published by this document at all
versioning.scope-matches-nothing A route was renamed after the change was written
versioning.scope-unforkable The scope matched an operation no private copy of the schema can be written for
versioning.unordered Your versions aren’t all dates or all semver
versioning.version-unstated The document declares api_version but writes no info.version

The full list, with what to do about each, is in the diagnostics reference.