Diffing your API
docuccino:diff compares two descriptions of the same API: the artifact you committed, and the
document your code produces right now. It prints what moved, marks each change breaking or not, and —
with --enforce — exits non-zero when a change needs a version bump it did not get.
That is two jobs in one command. In review it answers what does this pull request do to the contract? In CI it is the gate that stops the contract breaking by accident.
php artisan docuccino:diff docs/openapi.json2 changes (2 breaking)
BREAKING ~ [parameter] GET /api/v1/invoices/{invoice} parameters query:status (parameter.became-required) required: false -> true - [schema] GET /api/v1/invoices/{invoice} responses 200 application/json schema.properties.reference (schema.property-removed)No color and no timestamps, so the output is safe to paste into a pull-request comment or snapshot in
a test. The same run against an unchanged API prints No API changes.
In CI you rarely have the old artifact on disk — the checkout already has the new one. Read it out of git instead:
php artisan docuccino:diff docs/openapi.json --against=origin/mainThat runs git show origin/main:docs/openapi.json, so the path is repo-relative and the job needs no
second build.
A second argument names which configured document to build as the new side. Most commands run over
every document when you omit it; this one is the exception, and diffs the default document only —
so with several configured, say which:
php artisan docuccino:diff docs/partners.json partners --against=origin/mainPaired by identity, not by position
Section titled “Paired by identity, not by position”This is the part that makes the output worth reading.
Every operation, parameter, response and component schema in a Docuccino document carries a stable
id, and an id is a function of the thing rather than of where it sits in the file. An operation’s is
computed from its method and its path template with the placeholder names normalized away, so
/invoices/{invoice} and /invoices/{invoice_id} mint the same one. A parameter’s is computed from
that operation’s id plus its in and its name.
So rename the placeholder in a route:
Route::get('/invoices/{invoice}', [InvoiceController::class, 'show']);Route::get('/invoices/{invoice_id}', [InvoiceController::class, 'show']);2 changes (2 breaking)
BREAKING + [parameter] GET /api/v1/invoices/{invoice_id} parameters path:invoice_id (parameter.added-required) - [parameter] GET /api/v1/invoices/{invoice_id} parameters path:invoice (parameter.removed)The operation is the same operation. Its responses, its query parameters and its request body all pair and are compared. What changed is one path parameter, and that is what you are told about.
Note: an artifact carries no Docuccino identities, so nodes were paired by method + path.A renamed path parameter will read as a removal plus an addition. Re-export the artifact to pair by identity.
2 changes (1 breaking)
BREAKING - [operation] GET /api/v1/invoices/{invoice} (operation.removed)
NON-BREAKING + [operation] GET /api/v1/invoices/{invoice_id} (operation.added)Keyed on method and path, the endpoint at the old URL is gone and an unrelated one has appeared — and everything underneath it goes with them. Nothing compares the two, so a response that really did lose a field on the same commit is invisible here.
This is what every other OpenAPI differ sees, and the only way to get it out of docuccino:diff
is to hand it an artifact carrying no identities — which is why the two-line note is there. It is
printed on every structural run, never as a silent downgrade.
On one endpoint that is a review you can read against a review you have to redo by hand. Rename a
placeholder your API uses everywhere — {user} to {user_id}, say — and structural pairing reports
every endpoint under it removed and re-added, while identity pairing reports one parameter per
operation and nothing else.
The same property covers moves that are not renames at all — a shape that changed where it lives rather than what it says.
Every node OpenAPI lets a Reference Object stand in for — a path item, a request body, a response, a
parameter, a security scheme, a schema — is read as the thing it names on both sides before anything
is compared. So a whole 404 response
hoisted into components.responses
reports nothing at all, and neither does the body shape lifted into components.schemas beneath it.
Two operations that start sharing one 404 is the smallest version of it, and the whole of what that
commit says is that a component arrived:
1 change (0 breaking)
NON-BREAKING + [schema] components.schemas.NotFound (schema.added)Where a shape lives is representation, not contract. But resolved is not the same as invisible, and each case below follows from that one rule.
Two positions spelling the same pointer stay opaque, on purpose. Once both sides point at
NotFound, editing NotFound is reported once — at the component — rather than repeated under every
operation that reaches it. Adding a code property to the shared body, with two operations pointing
at it, is two lines and not four:
2 changes (0 breaking)
NON-BREAKING + [schema] components.schemas.NotFound.properties.code (schema.property-added) ~ [schema] components.schemas.NotFound.required (schema.required-added) required: ["message"] -> ["message","code"]A hoist that also changes the shape is still a change to the shape. The comparison is between what the inline copy said and what the component says, so a commit that moves a body and quietly drops a property is reported as dropping a property — at both the operations that lost it:
5 changes (2 breaking)
BREAKING - [schema] GET /api/v1/customers/{customer} responses 404 application/json schema.properties.message (schema.property-removed) - [schema] GET /api/v1/invoices/{invoice} responses 404 application/json schema.properties.message (schema.property-removed)
NON-BREAKING ~ [schema] GET /api/v1/customers/{customer} responses 404 application/json schema.required (schema.required-removed) required: ["message"] -> [] ~ [schema] GET /api/v1/invoices/{invoice} responses 404 application/json schema.required (schema.required-removed) required: ["message"] -> [] + [schema] components.schemas.NotFound (schema.added)Repointing reports the name and the shape. A component name is published — it becomes a type in
the client your consumers generate — so a position that moves from one component to another keeps
schema.ref-changed, which is not breaking by itself. What the new component says is compared
beside it, and that can be:
4 changes (1 breaking)
BREAKING - [schema] GET /api/v1/customers/{customer} responses 404 application/json schema.properties.code (schema.property-removed)
NON-BREAKING ~ [schema] GET /api/v1/customers/{customer} responses 404 application/json schema.$ref (schema.ref-changed) $ref: #/components/schemas/NotFound -> #/components/schemas/ProblemDetails - [schema] components.schemas.NotFound (schema.removed) + [schema] components.schemas.ProblemDetails (schema.added)Breaking depends on which way the data flows
Section titled “Breaking depends on which way the data flows”A schema serves one side of the wire or the other, and the same edit is not worth the same thing on
each. Widen a request and every client that worked still works. Widen a response and a client with a
switch over the old set meets a value it has no case for.
The diff reads that difference. Add one value to an enum published on both a query parameter and a response property, on one commit:
2 changes (1 breaking)
BREAKING ~ [schema] GET /api/v1/invoices/{invoice} responses 200 application/json schema.properties.state.enum (schema.enum-value-added) enum: ["draft","sent"] -> ["draft","sent","void"]
NON-BREAKING ~ [schema] GET /api/v1/invoices/{invoice} parameters query:status schema.enum (schema.enum-value-added) enum: ["draft","sent"] -> ["draft","sent","void"]One edit, one classification code, two verdicts. Which one a shared component gets is decided by where the document actually reaches it: a schema reached only from request positions is request-only, and anything the walk cannot place counts as read — ambiguity widens the cautious set, never the permissive one.
The rest of the classification follows the same instinct.
- Narrowing gates both sides. A request starts rejecting a body somebody was sending; a response starts promising less than it did.
- An obligation on the writer gates the request. A parameter or field that became required, a
formatthat arrived: breaking on a request, reported on a response. - A field a consumer reads gates the response. A property added is safe; a property removed is not.
- Gone is gone. A removed operation, parameter, response or status code — and a security scheme
that changed or disappeared while a
securityrequirement still names it. - Prose is never breaking. A description or a summary, an operation’s
deprecated, a security scheme’s, and every annotation keyword on a schema are reported — a reviewer asking what moved deserves the answer — and gate nothing. Four keywords beside them are not reported at all:default,readOnly,writeOnlyand a schema’s owndeprecatedare outside the comparison today, so changing one produces no entry. - A change nothing can order is treated as breaking. Two regexes, two
constvalues: no decision procedure orders them, so the change is reported and gates, because a false alarm costs you one look while a false “safe” costs your consumers a broken client. A keyword that is no part of the schema language at all is neither — it is data the document carries, and the comparison walks past it.
The full classification, code by code, is in the
docuccino:diff reference.
Make it a gate
Section titled “Make it a gate”On its own the diff is informational: it exits 0 however large the changeset. --enforce is what
turns it into a gate. It applies the document’s
versioning policy to the changeset and both
documents’ info.version, and exits non-zero on a violation.
php artisan docuccino:diff docs/openapi.json --against=origin/main --enforce2 changes (2 breaking)
BREAKING ~ [parameter] GET /api/v1/invoices/{invoice} parameters query:status (parameter.became-required) required: false -> true - [schema] GET /api/v1/invoices/{invoice} responses 200 application/json schema.properties.reference (schema.property-removed)
Versioning policy "semver" violated: Breaking changes require a major bump (1.4.0 → 1.5.0). (require ≥ 2.0.0)The changeset above it is the same plain text as ever; the verdict line is the one part the command styles, so it arrives red in a terminal that takes color and unadorned in a log that does not.
The policy is a property of the document, not a flag, so every run of the gate answers to the same rule:
versioning |
A breaking changeset passes when… |
|---|---|
none (the default) |
Never. No version bump rescues it — the contract is declared unbreakable. |
semver |
The major version went up. Still at 0.y.z, a minor bump is enough. |
date |
The new YYYY-MM-DD version is strictly later than the old one. |
A non-breaking changeset passes none on any versions at all, because none never reads them. Under
semver and date it passes only if both versions parse: an info.version either side cannot read
is a violation on its own terms, changeset or no changeset, so CI never green-lights a malformed
version. semver wants semver — v1.4.0 is not it — and date wants a leading YYYY-MM-DD.
semver is also the one policy that says what would have satisfied it. A violation under it carries
the lowest acceptable version, printed as the (require ≥ 2.0.0) above and given as requiredVersion
in the JSON payload, so the failure tells you the number to write. date and none state the rule
and leave the number to you — there is no next date to compute, and no version rescues a breaking
change under none.
Exit codes
Section titled “Exit codes”docuccino:diff exits 0 or 1. It never exits non-zero for finding changes — only --enforce
turns a changeset into a failure — so a 1 is one of two kinds of thing, and the message says which.
The gate doing its job: the versioning policy was violated under --enforce.
The run could not produce an answer, which it reports and stops rather than guessing past:
| What happened | What to do |
|---|---|
| The artifact is missing, unreadable, not JSON, or JSON that is not an object | Check the path; it resolves against the project root |
git show <ref>:<path> failed |
The ref must exist in the clone and the path must be repo-relative |
| The document key is not one you configured | Name a document from docuccino.yaml |
| The two documents were built by different identity algorithms | They cannot be paired safely; re-export the old side |
docuccino.yaml could not be read — missing, unreadable, or not a map of settings |
The build would run on defaults rather than your configuration, so it refuses first |
Docuccino is disabled (docuccino.enabled is false) |
Every command but docuccino:clear stops here |
Without --enforce, only the second kind can happen.
In a pull-request job
Section titled “In a pull-request job”name: API contracton: pull_request
jobs: diff: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 with: fetch-depth: 0 # --against needs the base branch in the clone. - uses: shivammathur/setup-php@v2 with: php-version: '8.4'
# Dev dependencies included, deliberately: the analysis engine is one of them. - run: composer install --no-interaction --prefer-dist - run: cp .env.example .env && php artisan key:generate
- run: php artisan docuccino:diff docs/openapi.json --against=origin/${{ github.base_ref }} --enforceThe old side comes out of git and the new side is built from the branch, so the job reports exactly what the pull request does — nothing about what happened before it. The production guide puts this step in the full docs pipeline, beside the export, the schema check and the staleness check that keeps the committed artifact honest.
The machine-readable changeset
Section titled “The machine-readable changeset”Run docuccino:diff --format=json and you get one object instead, for a gate that wants to do more
than pass or fail — post a comment, label the pull request, count what changed.
php artisan docuccino:diff docs/openapi.json --against=origin/main --enforce --format=json{ "document": "default", "breaking": true, "pairing": "identity", "disjointIdentities": [], "unreferencedComponents": [], "counts": { "total": 2, "breaking": 2 }, "changes": [ { "kind": "changed", "target": "parameter", "id": "par:v1:vbixr7narfsty4y3", "path": "GET /api/v1/invoices/{invoice} parameters query:status", "breaking": true, "code": "parameter.became-required", "fields": [ { "field": "required", "old": false, "new": true } ] }, { "kind": "removed", "target": "schema", "id": "res:v1:avz5vozhiusudzoa", "path": "GET /api/v1/invoices/{invoice} responses 200 application/json schema.properties.reference", "breaking": true, "code": "schema.property-removed" } ], "policy": { "satisfied": false, "policy": "semver", "code": "major-bump-required", "message": "Breaking changes require a major bump (1.4.0 \u2192 1.5.0).", "requiredVersion": "2.0.0" }}breaking and counts are the summary. code is a stable classification you can route on —
parameter.became-required is the same string next year as it is today. policy appears only under
--enforce. And pairing is the one to read before you trust any of it, which is the next section.
When the artifact carries no identities
Section titled “When the artifact carries no identities”Identity pairing needs identities on both sides. A spec written by hand, one produced by another
tool, or one Docuccino exported with --drop-ids has
none — so the diff pairs nodes by method and path on both sides instead, and says so before it says
anything else:
Note: an artifact carries no Docuccino identities, so nodes were paired by method + path.A renamed path parameter will read as a removal plus an addition. Re-export the artifact to pair by identity.The diff still runs, and still finds everything that structural pairing can find — a parameter that became required is caught either way. What is lost is rename detection, exactly as shown above. What you never get is a guess: the differ will not pair one side’s identities against the other side’s paths, because the two key spaces do not overlap and every operation would read as removed and re-added.
Getting identities back is usually nothing at all. An OpenAPI export carries a flat x-docuccino-id
on every node it mints one for by default, so an ordinary committed docs/openapi.json pairs by
identity already. The full artifact carries the whole
Docuccino extension natively, including the guide pages OpenAPI has nowhere to put:
php artisan docuccino:export --format=full --out=docs/api.full.jsonThere is a second, quieter failure the diff warns about. When both sides carry several ids for a kind of node and share not one of them, every one of those nodes reads as removed and re-added, and the breaking count describes nothing real:
Warning: both sides carry Docuccino identities, but no parameter id appears on both.Every one of those reads below as removed AND re-added, which is usually a pairing failure ratherthan an API change. Check the artifact is this document's own, and re-export it if it predates achange to how ids are minted.It is worded as a question because it is one — a wholesale rewrite of every node of that kind looks
identical. Check that the artifact belongs to this document, re-export it if it predates a change to
how ids are minted, and read the changeset again. The warning is advisory: --enforce never reads
it.
What it does not compare
Section titled “What it does not compare”The diff reads two documents and nothing else, which bounds it usefully.
It compares modelled fields and schema structure, so a provenance-only difference — the same API
described by a build that learned a field from a different layer — yields an empty changeset. It
never asks whether an example is valid; every published example is held to the schema beside it on
every build instead. And it cannot know whether your application
still behaves the way either document claims: that is what
contract testing is for, and
assertNoBreakingChanges() runs this same engine from inside your test suite.