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.
Your code is the newest version
Section titled “Your code is the newest version”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.
The whole loop
Section titled “The whole loop”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.
-
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 {}sinceis the first version whose document carries the new shape.descriptionis 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
::classconstant — the constructors accept nothing else — which is what lets Docuccino compile the declaration without running your app. -
Configure a document per version in
docuccino.yaml. Each one is an ordinary document with anapi_versionblock: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. -
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 -
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.
Let the diff write the first draft
Section titled “Let the diff write the first draft”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:
php artisan docuccino:version-changes docs/2026-06-01.uir.json v2026-09-01 --since=2026-09-01It 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.
What comes out
Section titled “What comes out”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"]}// components.schemas.Invoice — derived by undoing the rename{ "type": "object", "properties": { "id": { "type": "integer" }, "name": { "type": "string" }, "total": { "type": "integer" } }, "required": ["id", "name"]}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.
The verbs a change can declare
Section titled “The verbs a change can declare”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 arequiredentry, 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. Declaredrequired: trueit 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.
Putting a field back
Section titled “Putting a field back”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.
Ordering: which version is older
Section titled “Ordering: which version is older”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.
Serving the older shape
Section titled “Serving the older shape”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.
Scoping a change to some operations
Section titled “Scoping a change to some operations”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.
What versioning doesn’t cover
Section titled “What versioning doesn’t cover”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.
When something doesn’t apply
Section titled “When something doesn’t apply”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.