Commands
Docuccino registers the artisan commands below. Every one exits 0 on success and 1 on failure, so
each is safe to gate a CI job on. docuccino:explain adds one more code — 2,
for a query that named several operations — so a script can tell “not found” from “be more specific”.
Shared behavior:
- Enabled guard. Every command except
docuccino:clearaborts with exit1whenconfig('docuccino.enabled')isfalse, printingDocuccino is disabled (set docuccino.enabled = true to run this command).clearhas no guard, so you can always flush the cache. {document?}argument. Omit it to run over every configured document; pass a key to run one.docuccino:diffis the exception — with no{document}it diffs thedefaultdocument only, never all of them.docuccino:installtakes no{document}at all: it reports on every configured document. An unknown key errors and exits1. Per-document results aggregate: any single document failing fails the whole command.- Diagnostics.
export,validateandcacheprint diagnostics grouped by route signature in deterministic order;explainprints only the ones about the operation it is explaining;diff,clearandcoverageprint none.watchandinstallprint whatever the export they run prints, and nothing of their own — what they report is about your setup rather than about the document, which is a console message. --memory-limit. Accepted by every command that builds a document —export,validate,diff,cache,watch,explain,install— since inference runs a static analyzer inside the artisan process. Raise-only: a process already running with a higher limit is left alone, and-1is rejected. Same lever asengine.memory_limit, and the flag wins.clearandcoveragebuild nothing — one flushes the cache, the other reads an artifact your suite already asserted against — so neither takes it.- Long-running. Every command runs once and exits, except
docuccino:watch, which stays in the foreground until you stop it.
docuccino:install
Section titled “docuccino:install”Set Docuccino up in this application and generate a first document.
docuccino:install {--force : Replace existing configuration files with the shipped defaults} {--no-export : Set up without generating a first document} {--memory-limit= : Raise the PHP memory limit for inference (e.g. 2G)}| Flag | Values / default | Effect |
|---|---|---|
--force |
flag / off | Replaces an existing docuccino.yaml or config/docuccino.php with the shipped defaults. Without it an existing file is never touched — the command says which it left alone and names this flag — and an application whose build settings are still in config/docuccino.php gets docuccino.yaml written from those rather than from the defaults. |
--no-export |
flag / off | Finishes the setup without generating a document. Otherwise the command offers one, and --no-interaction takes the prompt’s default, which is yes. |
--memory-limit |
php.ini value, e.g. 2G / unset |
Raises the process memory limit before the first export runs — see the shared-behavior note above. |
The one command you run once rather than on every change, and the only one that writes anything outside an export path. Four steps, in order:
- Config. Publishes both configuration files —
docuccino.yamlat your project root, thenconfig/docuccino.php— byte for byte the same two thatvendor:publish --tag=docuccino-configwrites. It decides that per file: an existing one is left exactly as it is unless you pass--force, so an application that already keeps one gets the other and a second run changes nothing. - Routes. Reads your router and reports how many routes each configured document really matches. The count comes from the same resolver a build uses — attribute exclusions, closure filters and vendor package routes already subtracted — so it is the number your next export will document.
- Engine. Says whether the analysis engine is installed and, when it isn’t, prints the one command that fixes it alongside what the document loses meanwhile. Nothing here needs the engine: the command runs, and reports, either way.
- First document. Offers to run
docuccino:export, then prints the viewer URL for each document and the commands worth knowing next.
When nothing matches. The shipped routes.include is
api/*, and plenty of applications version their API somewhere else. Rather than leaving you to
guess, the routes step lists the prefixes your routes actually sit under, busiest first, and names the
value that would pick one up:
Routes──────"default" documents 0 of the 42 routes this application publishes (include: api/*).
"default" matched nothing. Your routes sit under:
Prefix Routes ─────── ────── v1/* 31 admin/* 11
Set documents.default.routes.include in docuccino.yaml — e.g. ['v1/*'].An application with no routes to document yet gets a sentence saying so, not a failure.
Exits 1 on a disabled install, a configuration file it could not write, or a failed first
export — setup succeeding while the export fails is still a failure.
On an application that hasn’t migrated yet. Build settings still sitting in config/docuccino.php
are a decision somebody made, the same way an existing file is, so the config step does not publish
the shipped docuccino.yaml over them — defaults there would document something else, and the file
appearing would stop anything refusing the build. It names the keys instead, leaves both files alone,
and skips the first export, which could only fail. Write the file as
Settings left in config/docuccino.php
describes, then run it again. --force still publishes the defaults.
docuccino:export
Section titled “docuccino:export”Generate and export API documentation from your routes.
docuccino:export {document? : The configured document key (defaults to every document)} {--format= : openapi-3.2 | openapi-3.1 | openapi-3.0 | full | postman | arazzo — writes this one format instead of the configured targets} {--out= : Output path (defaults to the matching target, else the document export path)} {--fail-on=none : none | error | warning | info | hint — the quietest severity that still makes the command exit non-zero} {--provenance=winners : none | winners | full — how much provenance a --format=full artifact keeps} {--drop-ids : Omit the flat x-docuccino-id member OpenAPI output carries by default (the artifact then diffs by method + path)} {--yaml : Emit YAML instead of JSON} {--memory-limit= : Raise the PHP memory limit for inference (e.g. 2G)}| Flag | Values / default | Effect |
|---|---|---|
document |
any configured key / all documents | Which document(s) to export. Unknown key → exit 1. |
--format |
openapi-3.2 | openapi-3.1 | openapi-3.0 | full | postman | arazzo / all configured targets |
Writes only this format, replacing the document’s export.targets for that run. full → OpenAPI 3.2 with the x-docuccino extension retained rather than stripped; openapi-3.1 and openapi-3.0 → the downlevel emitters; postman → a Postman Collection v2.1.0; arazzo → an Arazzo 1.1 workflow description. An invalid value errors (no silent fallback). |
--out |
path / the matching target, else export.path |
Overrides the output path — resolved against base_path() unless already absolute, and missing directories are created. Rejected when it would have to hold several artifacts at once: more than one document configured and no document argument, or a document with several export.targets and no --format — in both cases each write would clobber the last. Name a document, pass --format, or configure per-document targets. |
--fail-on |
none | error | warning | info | hint / none |
The quietest severity that still fails the run: anything reported at that severity or louder makes the exit code non-zero, and none never fails on severity. error catches errors only, warning adds warnings, info adds the recovery reports — an unrecoverable payload, a model with no readable columns, a validation rule that could not be read — and hint catches everything. The floor reads everything the run prints: what the build found, what an emitter reported while writing each artifact, and what reading your export configuration reported before the build started. An invalid value errors (no silent fallback) — a typo must not quietly remove the gate. Codes listed under diagnostics.accept still print but never fail the run; errors are never accepted. |
--provenance |
none | winners | full / winners |
How much provenance survives in the artifact --format=full writes. At --provenance=full every record is kept, including its overrode trail; at winners the records are kept but the trails dropped; at none provenance is stripped entirely. (The two are separate settings that happen to share a word: one names the artifact, the other how much trail is left in it.) An invalid value errors (no silent fallback). Only --format=full carries provenance at all — the OpenAPI emitters always drop it. |
--drop-ids |
flag / off | Omits the flat x-docuccino-id member. OpenAPI exports carry it by default: x-docuccino itself never survives emission (it holds provenance — source file, line, symbol — which has no business in a published spec), but the id is an opaque hash of members the document already publishes, and it is what lets docuccino:diff pair a committed artifact by identity instead of by method + path. Drop it if you want bytes indistinguishable from a hand-written spec, accepting the weaker diff. No effect on --format=full, which carries identities natively. |
--yaml |
flag / off | Emit YAML instead of JSON, for the single-target --format override. Configured targets state it in their own path instead (.yaml/.yml). Rejected with --format=full and --format=postman, which have no YAML form; --format=arazzo accepts it, and Arazzo is usually written as YAML. |
--memory-limit |
php.ini value, e.g. 2G / unset |
Raises the process memory limit before inference runs — see the shared-behavior note above. |
One build, many artifacts. With no --format, the command writes every target the document
configures — a single analysis feeding each emitter in turn. It prints Wrote <path> (<format>). per
target, in configured order, then any diagnostics.
--format and --out replace that list for the run rather than filtering it, so
--format=openapi-3.0 gives you a 3.0 file whether or not a 3.0 target is configured. When one is,
that target’s path is used — looked up by format, so which file you get never depends on how the list
happens to be ordered.
A target list the command cannot honor — an unknown format, two targets writing one file, a .yaml
path on full — fails with a config.export-* error before the build starts, so a wrong filename
never costs you an analysis. A write that fails prints Could not write <path>. instead of Wrote,
and the command exits non-zero.
Downlevel notes. OpenAPI 3.1 and 3.0 are older, smaller specs, so a downlevel sometimes has to
convert or drop something the full document carries. Every one of those steps prints a downlevel.* diagnostic
naming the construct and the JSON pointer it sat at, right after that target’s Wrote line — so the
artifact never quietly ships a weaker contract than your code describes.
--fail-on reads them like any other report, because what an artifact loses on the way out is a fact
about the contract you are shipping. Most of them are info, so a pipeline at --fail-on=warning
sees only the losses a 3.0 consumer would actually notice — a dropped webhooks section, a dropped
parameter, a schema keyword 3.0 has no word for. Where the older target’s price is one you have
already accepted, name the code under
diagnostics.accept: it keeps printing and stops
failing. The table under
OpenAPI 3.0 export lists what 3.0 changes,
and the diagnostics reference has the
severity of every code.
Postman collections
Section titled “Postman collections”--format=postman (or a postman export target) writes a Postman Collection v2.1.0 from the same
build as your OpenAPI file:
- Folders follow your tags, nested the way
tags.definitionsnests them. A tag with no operations is left out. {{baseUrl}}comes from your first configured server, and every server variable becomes a collection variable of its own — so switching tenant or version is one edit.- Auth maps onto Postman’s own block (
bearer,basic,digest,apikey,oauth2), with the credentials as{{variable}}references named after your security schemes. - Request bodies are generated from each schema, so a request is runnable rather than empty, and every documented response is saved as an example.
- Your own examples win. Where a request body, response or parameter publishes an
#[Example], the collection sends that payload rather than one derived from the shape; a map of several is read by its lowest key, the same rule every other reader of the document uses.
Postman cannot hold a JSON Schema, so a collection is a weaker contract than the OpenAPI file — keep
emitting both. Where something has no Postman equivalent at all (webhooks, callbacks, mutualTLS and
openIdConnect schemes) a postman.* diagnostic names it rather than letting the file go quiet.
Arazzo workflow descriptions
Section titled “Arazzo workflow descriptions”--format=arazzo (or an arazzo export target) writes an Arazzo 1.1
description of the workflows a document declares — the sequences of calls that get a consumer from
nothing to a finished outcome.
- Steps address operations by
operationId, resolved from the identity the workflow declared. That is the whole point of declaring a workflow against your code rather than writing the Arazzo file by hand: rename a route and the workflow still names the same operation, because what it stored was the operation rather than its name. - A success criterion is derived from the status your operation documents, so a step is something a runner can actually fail. An operation documenting more than one success gets none — picking between them would fail a workflow that worked.
sourceDescriptionspoints at the OpenAPI file exported beside it, so the two travel together.
A document that declares no workflows writes no Arazzo file at all, and says so with
arazzo.no-workflows. Arazzo requires at least one workflow and at least one source description, so
there is no empty form of the document — writing nothing beats writing a file that fails the
specification it names. A step whose operation the document does not publish, or publishes without an
operationId, is left out with arazzo.step-unresolved rather than emitted pointing at nothing.
Committing the output. Docuccino’s output is deterministic — identical code produces
byte-for-byte identical output. Commit docs/openapi.json (or the full document) and diff it in CI — see
docuccino:diff. For the committed artifact, --provenance=none (or winners,
accepting that source line numbers churn as code moves — churn is cosmetic and never alters
identities or the content hash) is the recommendation.
An OpenAPI artifact carries its node identities by default, which is what keeps that diff semantic —
without them it pairs nodes by method + path like any other OpenAPI differ, so renaming a path
parameter reads as a removal plus an addition rather than the no-op it is. --drop-ids opts out. The
diff says so when it has to fall back, and never guesses: it will not pair one side’s identities
against the other side’s paths.
docuccino:validate
Section titled “docuccino:validate”Validate the generated document(s), and every artifact they export, against their own schemas.
docuccino:validate {document? : The configured document key (defaults to every document)} {--fail-on=none : none | error | warning | info | hint — quietest extra severity that also fails (a schema violation always fails)} {--memory-limit= : Raise the PHP memory limit for inference (e.g. 2G)}| Flag | Values / default | Effect |
|---|---|---|
document |
configured key / all | Which document(s) to validate. Unknown → exit 1. |
--fail-on |
none | error | warning | info | hint / none |
Additional severity floor that also fails, read exactly as it is on export. Independent of the two schema checks. An invalid value errors, as it does on export. |
--memory-limit |
php.ini value, e.g. 2G / unset |
Raises the process memory limit; validation generates the document first, so it needs export’s headroom. |
This is the check-before-you-commit command, so it answers about both halves of what a build produces.
The built document, against the bundled Docuccino schema. A valid one prints
<key>: valid against UIR <version>.; an invalid one prints <key>: N schema violation(s). and
lists them as document.schema-invalid error diagnostics grouped by route.
Each artifact the document exports, against the published OpenAPI schema for the version that
artifact claims — the same check docuccino:export runs as it writes each file.
Every format in export.targets is emitted in memory and
read back; nothing is written, and no file on disk changes. You get one line per target:
<key>: openapi-3.2 artifact valid against its published schema. A full, postman or arazzo
target says artifact not read back against a published schema; not checked instead. The reason
differs by format: a full artifact was already validated against the UIR schema in the first half,
above; an Arazzo description answers to the Arazzo schema, but not as it is written; and a Postman
collection has no published specification at all.
Validating what you export rather than a fixed format is the point: if your pipeline ships
openapi-3.0, this tells you about the 3.0 file. That also means the downlevel.* reports a
downlevel target raises reach you here, before anything is written, and --fail-on reads them as it
does on export.
Either schema failing always fails the run, even with the default --fail-on=none — a document
that doesn’t answer to its own schema is not a quality note. --fail-on only adds severity gating on
top.
A broken export.targets is refused here too, before the
analysis: a target list that can’t be read names no artifact to check, and a document whose
configured artifacts can’t be written isn’t one to call sound.
docuccino:diff
Section titled “docuccino:diff”Diff a committed API artifact against the current document — semantic, id-based.
docuccino:diff {old : Path to the committed artifact to diff against, in any format it was exported in} {document? : The configured document key to generate as the new side (defaults to "default")} {--against= : Read `old` from this git ref (git show <ref>:<old>) instead of the working tree} {--enforce : Enforce the document's versioning policy; exit non-zero on a violation} {--format=terminal : terminal | json} {--memory-limit= : Raise the PHP memory limit for inference (e.g. 2G)}The diff is computed over stable x-docuccino.ids, so a path-param rename reads as “no change”
while a URI change reads as remove + add. Prefer a full artifact for old — it
carries the identities natively, and an OpenAPI artifact carries them unless it was exported with
--drop-ids.
When either side has no identities the diff pairs nodes by method + path on both sides, like any
other OpenAPI differ, and says so in its output (pairing: "structural" in the JSON payload). What
you lose is rename detection: a renamed path parameter reads as a removal plus an addition. 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 don’t overlap and every operation would read as removed and re-added.
Note that content pages live under the document x-docuccino and so cannot survive OpenAPI emission
at all; diffing an OpenAPI artifact against a document that has them reports each as added.
When both sides do carry several identities for a kind of node — operations, parameters, component
schemas — and share none of them, every one of those reads as removed and re-added. That is what a
pairing failure looks like, so the diff warns and names the kinds (disjointIdentities in the JSON
payload): check whether the artifact belongs to another document or predates a change to how ids are
minted, and re-export it if it does. The warning is advisory — --enforce never reads it — and stays
quiet when either side carries a single identity of that kind, where one node replaced by another says
the same thing and is far likelier.
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 through components on both sides, so one that
moved between inline and shared
is not itself a change, while an edit to a shared one is reported against every operation that $refs
it. A parameter written as a bare {"$ref": …} states no name and no in — the pair that tells one
parameter from another — so resolving it is also what lets the diff tell an operation’s $refed
parameters apart at all. Where a pointer resolves to nothing, the pointer itself does that job.
A schema follows the same principle, with one deliberate exception: where both sides spell the same
pointer the position is compared opaquely, by that pointer. That is what keeps the component’s own edits
reported once, at components.schemas.<Name>, instead of once under every operation reaching it. Where
the sides differ — inline against a pointer, or one component against another — the shapes are compared.
A pointer that moved between component names keeps schema.ref-changed, non-breaking on its own because
the name is published rather than the contract, and the two bodies are compared beside it, so a
repointing that also narrows is reported as a narrowing.
A components.schemas entry that nothing in either document references is a schema no operation can reach,
so no edit to it can change a request or a response. Its changes are still reported — a component name
becomes a type in a generated client — but never as breaking, and the diff names each component it stood
down (unreferencedComponents in the JSON payload) so the downgrade is never silent. Reachability is
transitive from the operations: a schema reached only through another schema counts, while one reached only
from a schema that is itself unreferenced does not. Docuccino publishes no unreferenced component, so this
comes up only when old is a hand-written or third-party artifact — where a shelf of unused schemas is
ordinary. Deleting a schema the new document still points at is the other side of the same coin: the
pointer is left naming nothing, so that is breaking (schema.removed-still-referenced) rather than the
tidying-up a plain schema.removed describes.
A schema published under a component name carries an id minted from the bytes it publishes, so editing the body mints a new one — a shared error shape that loses a required property is the common case. On ids alone that would read as one schema removed and another added under the same name, with nothing comparing the two bodies, so a schema whose id pairs with nothing on the other side is paired by the component name instead. Ids still come first, because they answer what a name cannot: a schema whose body did not move but whose name did keeps its id, and that is a rename rather than a removal plus an addition.
components.securitySchemes is diffed the same way, keyed by the name a security requirement uses. A
scheme some requirement still names is one every client has to satisfy, so changing how — its type, in,
name, flows, or any other member that isn’t prose — is breaking, and so is deleting it while a
requirement still asks for it. Dropping a scheme along with the requirements naming it is not: the API
stopped asking. A scheme no requirement anywhere names is stood down exactly like an unreferenced schema,
and named in the same list. OpenAPI writes security as a list of requirements, and an artifact that wrote
one bare — {"bearerAuth": []} where [{"bearerAuth": []}] belongs — is read as the requirement it plainly
states, wherever it sits: dropped instead, the scheme would look like one nothing asks for.
Webhooks are diffed as the operations they are, under webhooks.<name> in place of a path. A webhook is a
call the API promises to make, and a consumer writes an endpoint against it, so removing one or narrowing
what it sends is breaking on the same terms as an operation under paths.
A node written as a $ref is compared as the component it points at. The contract comes
from the component and never from the pointer’s neighbours: a parameter takes its name, in, required,
deprecated and schema from there, a response its headers and content, and a path item the
operations and shared parameters under it — so a required: false next to a pointer at a component that
says required: true changes nothing, and the diff reports nothing.
A summary or a description beside the pointer still wins — OpenAPI gives a Reference Object those two
members of its own and says every other sibling is ignored — so a style, an explode or an x-
extension written there describes nothing and the component’s answer stands.
A schema pointer is the exception, because JSON Schema 2020-12 keeps every keyword beside a $ref
in force: the schema at such a position is the intersection of the pointer and its neighbours, and
neither one states it. So only a bare pointer is read through — $ref alone, or with annotation
keywords, which are read through and override the component’s. A pointer with anything beside it that
constrains the value is compared as written rather than flattened into a shape neither side declares —
at the position, or at whichever hop of a chain first states one. Docuccino spells a hoisted shape as
the pointer alone, so this arises only against a hand-written or third-party artifact.
A pointer the diff cannot follow — a name the document does not declare, a chain, a cycle, a pointer into
another file — is a comparison it cannot make, and it says so rather than guessing. Where a path item or a
request body is spelled that way on one side only, that endpoint or that body drops out of the comparison
and the changeset carries one entry naming the pointer (pathItem.unresolved-ref,
requestBody.unresolved-ref): reporting every operation under it removed would blame a pointer it could
not open, and reporting nothing would hide it.
Whether that entry is breaking is whether the new document is the reason. A local #/components/…
pointer at a name it does not declare publishes nothing at that position for anybody reading the
document, so the endpoint — its parameters, its response schemas, its authentication requirement — or the
body is gone, and the entry is breaking. That is what renaming or removing a components entry a pointer
still names leaves behind. A pointer into another file, a chain or a cycle is the resolver stopping rather
than the document being wrong — the endpoint may be whole where it lives — and stays non-breaking.
Repairing a broken pointer is not a breaking change either, and where both sides carry the same pointer
for the same reason the document did not change there and nothing is reported.
A schema pointer names no separate entry, because a schema position always has a comparison to make. A chain is followed: a component whose whole body is a pointer at another component resolves to the shape at the end of it, however many names lie between, and the names along the way are reported moving like any other. What compares as the keywords written at the position is a pointer the resolver reaches no shape through at all — a name the document does not declare, a pointer into another file, or a chain that comes back to a name already being resolved. Against an inline shape on the other side that reads as that shape’s keywords leaving, which is the degraded direction on purpose: a position whose pointer leads nowhere describes no value, and over-reporting costs a look where under-reporting would let a narrowing past as safe.
A schema reaching itself, directly or around a loop, is bounded rather than trusted: the pointer pair being resolved is held open for the descent beneath it, so a recursive schema compares to its depth and stops — and a chain rides on that same bound rather than counting hops of its own.
An operation’s parameters are its own plus the ones its path item declares for every operation under it,
minus any the operation restates for the same name and in — the override OpenAPI specifies. Docuccino
writes parameters on the operation, so this only comes up when old is a hand-written or third-party
artifact, which is exactly where a parameter would otherwise go uncompared.
An annotation-only edit is not a contract change. A schema’s title, description, example,
examples, externalDocs and $comment say what a value means and nothing about what it may be, so a
change to one is reported as schema.annotation-changed and is never breaking — under any
versioning policy, none included. It is still in the
changeset, under NON-BREAKING, because a rewritten description or a corrected #[Example] is worth
seeing; it is just not worth failing a pipeline over. A change beside an annotation is unaffected either
way — a narrowed type and a rewritten description on one schema are two changes, one breaking and one
not, and neither hides the other.
Four keywords that read like documentation are deliberately outside that set, because each says
something about the value rather than about what it means: default (what the server fills in when the
value is omitted), readOnly (whether it may be sent), writeOnly (whether it will come back) and
deprecated (whether it is being withdrawn). Being outside the set is not the same as being reported:
the diff does not compare these four at all today, so changing one produces no entry in the changeset.
A schema’s type is read as the SET of instance types it allows, so string becoming
[string, integer] is a direction rather than a rewrite. A type arriving where the value was untyped is
schema.type-added and a set shrunk is schema.type-narrowed, both breaking on either side; a set grown
is schema.type-widened and the constraint leaving is schema.type-removed, both safe on a request and
breaking on a response; and two sets neither of which contains the other are schema.type-changed,
breaking. The null member of a union is not read here at all — it is the same statement as
nullable: true and is read once, below — so migrating a schema between the two spellings reports
nothing rather than a phantom widening.
Every direction gets the same verdict, and an enum is the reading the rest are measured against. A
value leaving one (schema.enum-value-removed) or an enum arriving where nothing constrained the value
(schema.enum-added) narrows, and a narrowing is breaking on both sides: a request starts rejecting a
body a writer used to send, and a schema’s request/response role can under-state its audience. A value
joining one (schema.enum-value-added) or the constraint being dropped (schema.enum-removed) widens,
and a widening is safe on a request — old writers stay valid — and breaking on a response, because a
reader meets a value it has no case for and a strongly-typed generated client fails outright. A change
nothing can order is breaking too, for the reason a false alarm costs you one look and a false “safe”
costs your consumers a broken client. Everything below is that one rule applied to another keyword.
Composition and conditional keywords are read, each on its own terms, because their direction is not
the same. allOf is an intersection, so a branch added narrows the contract (schema.all-of-branch-added)
and a branch removed widens it (schema.all-of-branch-removed) — safe on a request, breaking on a
response. The whole allOf leaving reads the same way, as does a not leaving (schema.not-removed,
the value it rejected is admitted again) and a contains that was asserting something leaving
(schema.contains-removed, the array need no longer hold a matching element). Each arriving is the
narrowing on the other side of the same coin — schema.not-added, schema.contains-added — and breaking
on both. anyOf and oneOf are
unions, so a branch removed narrows either way, while a branch added is safe on a request and breaking on
a response — a reader can now meet a shape it has no case for, exactly as it can when a value joins a
response enum. That is a branch of a union that was
already there; the union keyword arriving is a different change, and breaking on both sides, because the
schema it landed on was not an empty union but an unconstrained one. It leaving reads like
schema.enum-removed: a request widens, while a response reader loses the closed set of shapes it typed
against. Branches are paired by what they are — a schema’s identity, then the component it names, then
its content — never by where they sit, so reordering a oneOf is not a change, while swapping one branch
for another is a removal plus an addition. contains demands a matching element, so it arriving narrows
unless its own bounds say it asserts nothing — minContains: 0 with no maxContains capping how many may
match — and those two bounds keep a code of their own (schema.contains-bound-narrowed,
schema.contains-bound-widened) because they bound a keyword rather than the value. They take the same
verdict everything else does: raising a minContains or lowering a maxContains is breaking on both
sides, and relaxing either is breaking on a response, exactly as a relaxed maxItems is. prefixItems pairs by index, because index 2 constrains the third
element and nothing else.
Refinement keywords are read too, each in its own value space. Tightening a maxLength, raising a
minimum, turning on uniqueItems, rewriting a pattern — every one of these changes what the API
accepts or returns, and every one used to pass --enforce as safe. A bound tightened is
schema.refinement-narrowed and breaking on both sides: a request starts rejecting a body a writer used
to send, and a schema’s request/response role can under-state its audience. A bound relaxed is
schema.refinement-widened, safe on a request and breaking on a response for the same reason a value
joining a response enum is — a reader can now meet a value it has no case for. Each change names the
keyword in its fields, so the changeset says which bound moved and from what to what.
Which way a keyword moves is the keyword’s own question, not its family’s: maximum narrows downward and
minimum narrows upward, multipleOf narrows to a multiple of what it was (2 → 4, never 2 → 3), and
uniqueItems narrows off-to-on. A keyword’s absence counts as a value, so writing out minLength: 0 is
a restatement of the default and reports nothing, while minimum: 0 is a floor arriving where there was
none. pattern, const, contentEncoding and contentMediaType have no order between two values at
all: one arriving narrows and one leaving widens, but a value changed is reported as
schema.refinement-changed and counted breaking rather than guessed at — deciding whether one regex
accepts everything another does is not a question to answer by eye at a release gate.
exclusiveMinimum and exclusiveMaximum mean different things in different drafts — a boolean modifier
on the minimum/maximum beside them in draft-04, the bound itself in 2020-12 — and both spellings are
read, which matters when old is an artifact written before you migrated dialects. Two numbers compare
as the bound; two booleans compare as the flag, where absent is “not exclusive”. A boolean on one side
against a number on the other is the one comparison that cannot be made without folding in the sibling
keyword, so it is reported and counted breaking.
A discriminator is read member by member, because it is the keyword that decides which type a client
builds. Rename the tag property and every client reads a field that is no longer the tag: that is
schema.discriminator-changed and breaking on both sides, with no reading where clients keep working. A
mapping is a map, so its entries pair by tag value and reordering one is not a change. An entry removed
is schema.discriminator-narrowed and breaking either way; an entry added is
schema.discriminator-widened, safe on a request and breaking on a response, since a reader now meets a
variant it has no case for; and an entry repointed — the same tag, a different schema — is
schema.discriminator-changed, breaking, and the edit this exists for: the payload still validates and the
client still compiles, so it fails at run time in your consumer’s application as a mis-typed object. The
keyword itself arriving is schema.discriminator-added, breaking on both sides because payloads must now
carry the tag; it leaving is schema.discriminator-removed, safe on a request and breaking on a response,
exactly as a dropped enum is. Every other member is compared as a value, so a member OpenAPI adds to the
object later is read the day it appears.
nullable is read beside the type it belongs to. Withdrawing a null — nullable: true becoming
false, or the keyword going while the type stays — is schema.nullable-narrowed and breaking on both
sides: the server stops accepting a value your clients are still sending. Admitting one is
schema.nullable-widened, safe on a request and breaking on a response, for the same reason a value
joining a response enum is. Because OpenAPI 3.0’s nullable: true and 3.1’s type: [string, null] are
one statement in two dialects, migrating between them reports nothing at all — the keyword is read
together with the type union beside it, and the type comparison leaves the null member to it, so one
edit is one finding and switching spellings is not mistaken for a contract change. Writing nullable: false out where nothing was written reports nothing either, since that is what
its absence already meant. A nullable that is no boolean at all — the sort of thing a hand-written old
artifact carries — is a change nothing can order: schema.nullable-changed, breaking on both sides.
$id, $anchor and $schema are read as what they are. A $ref can name an $id or an $anchor,
and the diff resolves no pointers, so a name changed or removed may leave one naming nothing:
schema.identity-changed, breaking. An $anchor arriving is the same code and not breaking — nothing
could have pointed at it before. An $id arriving is breaking, because an $id is not only a name: it is
the base every $ref beneath it resolves against, so a pointer that resolved at the document root now
resolves inside the new resource and every generated client’s target moves. Nothing Docuccino generates
mints an $id, so this reaches you only through a hand-written overlay or a third-party old artifact —
where a false alarm costs one look and the alternative costs your consumers a silently repointed client.
$schema names the dialect every keyword beside it is read in, so a comparison
that spans a change to it has compared two languages; that is schema.dialect-changed and breaking,
including when an explicit $schema arrives where there was none, because nothing in the diff can tell a
restatement of the dialect already in force from a migration to another one.
Two positions have no direction to report, and the diff says so rather than guessing. Under not,
narrowing the subschema widens what the API accepts; under if, a change moves values between the
then and else branches. A change under either is reported at the keyword carrying it and counted as
breaking: a false alarm costs you one look, and a false “safe” costs your consumers a broken client.
The $defs and definitions stores are read the same conservative way, since a $ref can name any
member. then and else are not in that group — narrowing either narrows the whole schema, so both are
classified like any other subschema.
What the diff does not see. It compares a schema’s own keywords, so prose that lives beside a schema
rather than inside one is not read: an example or examples on a media type — which is where a
recorded example is published — the same pair on a parameter, and
info.title/info.description on the document. A re-recorded example therefore produces no changeset
entry at all, and never has.
Whether an example is valid is a different question, and the diff never asks it. Every example the document publishes is held to the schema beside it on every build, so an example that stopped matching its own type is caught there — see Examples — rather than by a gate that only ever compares two artifacts.
| Flag | Values / default | Effect |
|---|---|---|
old (required) |
path | The “old” side. Missing/unreadable/invalid-JSON → exit 1. |
document |
configured key / "default" |
Which document to generate as the “new” side. Unknown → exit 1. |
--against |
git ref, e.g. HEAD / unset |
Reads old via git show <ref>:<old> (so old must be repo-relative) instead of from disk. Refs/paths starting with - are rejected; git failure → exit 1. |
--enforce |
flag / off | Enforce the document’s versioning policy; a violation exits non-zero. Without it, the diff is informational and exits 0 even with changes. |
--format |
terminal | json / terminal |
terminal renders a human changeset (+ a satisfied/violated policy line when enforced); json prints a machine payload. |
--memory-limit |
php.ini value, e.g. 2G / unset |
Raises the process memory limit; the “new” side is generated, so the diff needs export’s headroom. |
Output
Section titled “Output”terminal prints a one-line summary (4 changes (1 breaking), or No API changes.), then a
BREAKING block ahead of a NON-BREAKING block, each line marked + added, - removed, ~
changed. No color, no timestamps — safe to paste into a PR comment.
json prints one object:
{ "document": "default", "breaking": true, "counts": { "total": 4, "breaking": 1 }, "changes": [ { "kind": "removed", "target": "parameter", "id": "par:v1:k4v2mzq7tn3xrs6b", "path": "GET /api/invoices parameters query:status", "breaking": true, "code": "parameter.removed" } ], "policy": { "satisfied": false, "policy": "semver", "code": "major-bump-required", "message": "Breaking changes require a major bump (1.4.0 → 1.5.0).", "requiredVersion": "2.0.0" }}kind is added | removed | changed; target is operation | parameter | response |
schema | securityScheme | page; code is a stable classification such as parameter.removed,
parameter.became-required, response.content-removed, schema.type-narrowed or
securityScheme.changed. A change carrying field-level detail adds a fields array. The policy
member appears only with --enforce, and requiredVersion only on a violation.
--enforce and versioning policies
Section titled “--enforce and versioning policies”The policy comes from the document’s versioning config value,
not a CLI flag. It weighs the changeset’s breaking changes against both documents’ info.version.
The three policies differ mostly in what a breaking changeset demands:
versioning |
A breaking changeset passes when… | Verdict codes |
|---|---|---|
none (default) |
Never. No version bump rescues it — the contract is declared unbreakable. Versions are never inspected. | breaking-forbidden |
semver |
The major version went up (1.4.2 → 2.0.0). While still at 0.y.z a minor bump is enough (0.3.1 → 0.4.0), per semver §4. |
major-bump-required, minor-bump-required, invalid-version |
date |
The new YYYY-MM-DD version is strictly later than the old one. A trailing suffix is ignored for the comparison, so 2026-08-01 and 2026-08-01-rc1 compare equal. |
new-date-required, invalid-date-version |
A non-breaking changeset passes under all three. Note that semver and date parse both versions
first, so an unparseable info.version on either side is a violation even when nothing about the API
changed — CI never green-lights a malformed version.
An unrecognized versioning keyword resolves to none — a typo fails closed rather than quietly
waving breaking changes through.
Because none is the default, a first --enforce run rejects every breaking change outright.
That’s usually what you want on an internal API; set versioning to semver or date when you’re
ready to ship breaking changes behind a version bump.
On a violation the verdict carries the lowest version that would satisfy the policy, printed as
(require ≥ 2.0.0) and surfaced as requiredVersion in the JSON payload. Only an unsatisfied
verdict makes --enforce exit non-zero; without --enforce the diff is informational and exits 0
however large the changeset.
Commit the artifact, then fail the build when it drifts from the code or breaks the contract without a version bump:
# 1. The committed spec must match the code.php artisan docuccino:export --provenance=nonegit diff --exit-code docs/openapi.json
# 2. The change must be structurally valid…php artisan docuccino:validate --fail-on=warning
# 3. …and must not break the contract without the version bump the policy demands.php artisan docuccino:diff docs/openapi.json --against=origin/main --enforceStep 3 reads the artifact as it exists on main (git show origin/main:docs/openapi.json) and
diffs it against the document generated from the branch, so the check reports exactly what the pull
request changes about your API.
docuccino:cache
Section titled “docuccino:cache”Build and cache the API document(s) for the runtime endpoint.
docuccino:cache {document? : The configured document key (defaults to every document)} {--memory-limit= : Raise the PHP memory limit for inference (e.g. 2G)}Builds each selected document and stores its OpenAPI 3.2 payload — JSON, default emit options — under
docuccino:document:<key> in the cache.store Laravel
cache store (null uses your default store), so the runtime viewer can answer
viewer.source: cache without a rebuild. Stored
forever: re-run the command to refresh it, typically as a deploy step.
Prints Cached document "<key>". per document, then any diagnostics — the build’s, and whatever the
emitter reported while producing the payload. There is no --fail-on here, so severity never affects
the exit code, with one exception: if the payload isn’t a valid document of its own format
(document.openapi-invalid) the command caches it and still exits non-zero, the way export does for
a file it wrote. Otherwise it fails only on a disabled install or an unknown document key.
--memory-limit applies here too — worth knowing, since warming the cache is usually a deploy step.
docuccino:clear
Section titled “docuccino:clear”Clear the cached runtime API document(s).
docuccino:clear {document? : The configured document key (defaults to every document)} {--fragments : Also empty the per-operation fragment cache}The inverse of docuccino:cache: forgets each selected document’s cached payload and prints
Cleared cached document "<key>". It is the one command with no enabled guard, so it runs even
when docuccino.enabled is false — you can always flush a stale payload out of an installation
you’ve just switched off. Fails only on an unknown document key.
--fragments additionally empties the fragment cache —
the per-operation store behind cache.enabled — and prints
Cleared N cached operation fragment(s). That store is shared by every document, so naming one
document still empties all of it (an unknown key fails the command before anything is cleared), and it
is emptied whether or not the fragment cache is currently enabled — it is the supported way to recover
from a fragment store you no longer trust, instead of deleting storage/docuccino/fragments by hand.
docuccino:watch
Section titled “docuccino:watch”Rebuild API documentation as your code changes, and refresh an open viewer.
docuccino:watch {document? : The configured document key (defaults to every document)} {--interval=1 : Seconds between polls of the watched files} {--memory-limit= : Raise the PHP memory limit for inference (e.g. 2G)}| Flag | Values / default | Effect |
|---|---|---|
document |
any configured key / all documents | Which document(s) to rebuild. Unknown key → exit 1. |
--interval |
seconds, 0.25 and 2 are both fine / 1 |
How often the watched files are re-read. A value that isn’t a positive number errors (no silent fallback). |
--memory-limit |
php.ini value, e.g. 2G / unset |
Passed through to each rebuild — see the shared-behavior note above. |
Start it beside php artisan serve and leave it running:
php artisan docuccino:watchIt builds once, then rebuilds whenever a file the build depends on changes, and prints which file
triggered it. Ctrl+C stops it.
What it watches
Section titled “What it watches”Not a pattern you have to keep in sync with your project — the same files the build itself recorded as its inputs:
- Everything behind an operation. Each cached operation stores the files it was recovered from: the controller, everything a parent class or trait answered for it, every file a traced helper walked, and any file an attribute read. Editing one controller rebuilds one operation.
- Everything that decides all of them.
docuccino.yaml,config/,routes/,composer.jsonandcomposer.lock, each document’scontent.dirtree, itswebhooks.dirtree, its overlay files, and theengine.configfile if you name one. These are watched as directories, so a route file, a content page or a webhook class you add mid-session counts too.docuccino.yamlis watched whether or not it is there, because the file appearing is itself the edit a session has to notice.
The artifacts a build writes are deliberately excluded — watching its own output would rebuild forever.
Watch mode turns the fragment cache on for the builds it runs
(via DOCUCCINO_FRAGMENT_CACHE), which is what makes a rebuild incremental and what gives it the
list above. php artisan config:cache cannot get in the way of that: cache.enabled is read from
docuccino.yaml and the override from the environment the rebuild is handed, so neither goes through
the config a cache would bake.
If the first build stored nothing, watch says so rather than leaving you to notice that editing a controller changes nothing: only the roots above are watched, and the usual cause is a fragment directory it could not write to.
Live viewer refresh
Section titled “Live viewer refresh”While docuccino:watch is running, an open viewer page subscribes to
<viewer.route>/reload and refreshes itself when a rebuild changes the document. The channel answers
only during a watch session, so there is nothing to switch off in production — see live reload while
you work.
Why each rebuild is a new process
Section titled “Why each rebuild is a new process”Watch runs docuccino:export in a fresh PHP process rather than rebuilding in place, so every
rebuild documents your code as it is now — including a route or a class you added mid-session. It
costs nothing you’d notice: the fragment cache is on disk, so the new process picks up every
operation the last one built and re-analyzes only what changed.
A rebuild that hasn’t finished in 15 minutes is stopped and reported as a failed build, so an analysis that wedges costs you one rebuild rather than the session.
docuccino:coverage
Section titled “docuccino:coverage”Report which documented responses and webhook deliveries your test suite exercised.
docuccino:coverage {document? : The configured document key (defaults to every document)} {--path=* : A coverage log directory to merge (repeatable; defaults to the document's own)} {--min=0 : Fail below this percentage of documented responses and webhook deliveries} {--reset : Delete the logs and exit, leaving the directory ready for a run}| Flag | Values / default | Effect |
|---|---|---|
document |
configured key / all | Which document(s) to measure. Unknown → exit 1. |
--path |
directory, repeatable / the document’s coverage.log |
Directories to merge. Subdirectories are walked, but name each shard’s directory rather than the tree they land in — only a directory you named can be reported as missing. |
--min |
0–100 / 0 |
Floor, measured against documented responses and webhook deliveries. Below it the command exits 1. A value outside the range errors. |
--reset |
flag / off | Deletes the log files in those directories and exits 0, reporting how many. Nothing else in them is touched. |
The gated number is documented responses and webhook deliveries. A documented 422 is a promise of
its own — it is what a consumer writes a catch against — so a suite that only asserts the happy path
has touched every endpoint and proved none of them. The report prints operations exercised beside
responses and deliveries exercised, and compares only the latter to --min.
Each documented webhook is counted alongside them, as one delivery row lit by a passing
assertValidWebhook() — otherwise a document whose outbound half nothing asserts reads as fully
covered. A webhook’s own responses are what the receiver answers, and nothing in a sending
application’s suite can exercise one, so they are never counted.
It reads the artifact your suite asserted against — never a fresh build — so the command and the
contract assertions can only ever be talking about the same
responses and deliveries. Operations are matched by stable x-docuccino.id, so a renamed route reads as still
covered rather than as one endpoint vanishing and another appearing.
Why a command and not an assertion. Coverage is a question about the whole suite, and no test can
see the whole suite: a parallel worker holds its own share, a shard holds its own machine’s, and neither
can know when the others have finished. So each process writes a log and this merges them afterwards —
the same shape line coverage has, where workers write and the runner merges once they are done. Turn the
recorder on in your test bootstrap with ApiContract::recordCoverage(); the wiring and the CI recipe are
on Contract testing.
An incomplete merge never produces a number. A directory it cannot read — absent, or there and refusing to open, at the top of a named path or nested anywhere under one — a directory holding no log, and a file that doesn’t read back as coverage entries each fail the command with the path named, before any percentage is printed. A gate that quietly measured three of four shards is worse than no gate.
Logs accumulate until --reset clears them, so a report that unioned more than one run says how far
apart its logs were written, above the numbers.
Coverage — default──────────────────/app/storage/docuccino/coverage8 log files, 29 entries
Docuccino contract coverage: 29 of 41 documented responses and webhook deliveries exercised (70.73%, floor 85%).21 of 23 documented operations were reached at all — the floor is measured against responses and deliveries, not operations.
Never exercised: GET /api/invoices 422 op:v1:k9wd2mrb7ks9tvzq GET /api/invoices/{invoice} 404, default op:v1:h4dqx2mrb7ks9tvz POST /api/invoices/{invoice}/void 201, 409, 422 op:v1:p6nw3jc8ygf5s0ea POST webhooks.invoice.paid delivery op:v1:m2xq8bd4nf6ha1cy
Cover them, or — if this is the honest measured floor for now — move the floor to 70 and ratchet it up from there.The middle column is the statuses that operation documents and the suite never produced, in the document’s own order. An operation is listed as soon as one of its responses is unproved, so an endpoint whose happy path is covered and whose errors are not appears with its errors named.
A response key no status can resolve to — 4xx where OpenAPI spells the range 4XX, or a word like
ok — is in neither count, because nothing could ever exercise it and a floor of 100 would be
unreachable if it were. The report names each one under the numbers so a short denominator is never a
mystery.
docuccino:explain
Section titled “docuccino:explain”Explain why one endpoint is documented the way it is, layer by layer.
docuccino:explain {route : The operation — "POST /api/invoices", a URI, a route name, an operation id, or part of any of them} {document? : The configured document key (defaults to every document)} {--method= : Narrow a URI several verbs answer (get, post, put, patch, delete, …)} {--field= : Explain one field, printing every value in full (e.g. requestBody, responses.201.description)} {--json : Print the trail as JSON instead of the report} {--memory-limit= : Raise the PHP memory limit for inference (e.g. 2G)}Every value in the document carries a record of who put it there —
provenance — and this reads it back. Point it at an endpoint
that looks wrong and it prints, field by field, which
precedence layer won, what that value displaced, the file:line
to open next, and what to change to override it.
It reads and prints only: nothing is written, and no cache is touched.
php artisan docuccino:explain "POST /api/invoices"POST /api/invoices──────────────────InvoiceController@store · document "default" · route invoices.store
Precedence, low to high — the highest rung that writes a field wins it:fallback › inference › integration › docblock › attribute › overlay › config✓ published ✗ shadowed
operation requestBody ✓ integration {"required":true,"content":{"application/json":{"schema… integration:form-request · app/Http/Controllers/InvoiceController.php:38 → set it with #[BodyParameter(name: 'total')] summary ✓ attribute "Raise an invoice" app/Http/Controllers/InvoiceController.php:36 ✗ docblock "Store a new invoice." → edit the attribute above, or outrank it with an overlay
responses.201 description ✓ attribute "The invoice as stored." app/Http/Controllers/InvoiceController.php:36 ✗ fallback "Created" → edit the attribute above, or outrank it with an overlay
responses.422 → #/components/responses/UnprocessableEntity from integration:implicit-response · app/Http/Controllers/InvoiceController.php:38 · implicit:validated-request component ✓ integration "UnprocessableEntity" → set it with #[Response(status: 422, errorComponent: 'InvoiceNotFound')], or #[ErrorComponent] on the exception or its render method description ✓ integration "Unprocessable Entity" → set it with #[Response(status: 422, description: '…')]
5 fields · 7 contributions · 2 shadowedA shadowed value is recorded by producer only — the trail keeps what lost, not where it came from.1 value shortened to fit — `--field=<name>` prints one in full.Reading it. Each block is one node of the document, named the way you would point at it — the
operation itself, then its parameters, request body and responses, then anything they $ref. Under
each field is the stack of layers that reached it: ✓ is the value the document publishes, and every
✗ under it is a value a lower rung wrote and lost with. A → after a node name is the component it
points at; a from line means every field on that node came from the same place, and a → line under
it means they all take the same override.
The rung is always spelled out beside its color, so the report reads the same piped to a file, under
--no-ansi, and in a CI log. A confidence only appears when it is low enough to act on — a mapper
that converted a type cleanly reports 0.9, so printing it everywhere would just teach you to skip
it. The precedence ladder prints only when something was actually shadowed; on an endpoint where
nothing competed it would be explaining a competition that never happened.
The → line: what to change
Section titled “The → line: what to change”Knowing which layer won is only half an answer. The other half is derivable from it, so each field gets one line saying how to take it:
| The winning rung | What the line says |
|---|---|
fallback, inference, integration, docblock, and an attribute writes that field |
The attribute, spelled with this endpoint’s own values — set it with #[QueryParameter(name: 'filter[status]')] |
fallback, inference, integration, docblock, and no attribute writes it |
The generic truth — no attribute writes this — an overlay outranks docblock |
attribute |
edit the attribute above, or outrank it with an overlay — the file:line above it is the attribute |
overlay |
edit the overlay that set it; only config outranks an overlay |
config |
config is the top rung — edit docuccino.yaml |
An attribute is named only where it genuinely writes that field on that node: #[Group] really is
what sets tags, and the name a shared error body publishes under is written by #[ErrorComponent] or
by #[Response(errorComponent:)] — never by #[Response]’s other arguments. Where a field takes more
than one anchor the line names them all, listing the one written on the action first. A
lever that would do nothing is worse than no lever, so everywhere else the answer is the generic one,
which is still actionable: an overlay can write any field at
all, and it outranks everything except config.
--field: one field, whole
Section titled “--field: one field, whole”The scannable report shortens a long value to keep the columns readable — and the value you are
debugging is exactly the one likely to be long. --field prints one field’s whole stack with every
value in full:
php artisan docuccino:explain "POST /api/invoices" --field=requestBodyPOST /api/invoices──────────────────InvoiceController@store · document "default"
operation requestBody ✓ integration integration:form-request · app/Http/Controllers/InvoiceController.php:38 { "required": true, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/StoreInvoiceRequest" } } } }
→ set it with #[BodyParameter(name: 'total')]It is narrowed exactly as the route argument is — an exact node.field path, then an exact field
name, then a fragment — and answers on the same three exit codes. A name several nodes carry
(description is the usual one) lists them with the rung that won each, and exits 2:
2 fields match "description".─────────────────────────────
Field Rung ───────────────────────── ─────────── responses.201.description attribute responses.422.description integration
php artisan docuccino:explain "POST /api/invoices" --field=responses.201.descriptionA field the trail names but the document does not carry reads (removed by this layer) — that layer
wrote a deletion, which is a decision about the field rather than a missing value.
What the build reported about it
Section titled “What the build reported about it”Under the trail come the diagnostics for that operation — matched on the route signature the
report already groups by, so nothing about the rest of the document appears. The trail says which rung
wrote a field and where from; it has no room for why a producer could only answer vaguely, and that is
usually the actual question. An error response filed under 500 is the case people arrive with: the
trail names the fallback rung and the action, and the notice beside it names the exception, the file
and line its throw is written at, and which fold gave up on its status.
Diagnostics for the whole document, and the --fail-on gate over them, stay with
docuccino:export.
Naming the endpoint
Section titled “Naming the endpoint”You’ll usually run this straight after looking at the viewer or your
exported openapi.json, and both show POST /api/invoices — so that is the primary spelling. A
route name works too but is never required: closure and unnamed routes are common, and they are
disproportionately the ones that document badly.
The argument is tried as, in order:
- an exact route name —
invoices.store; - an exact operation id —
storeInvoice, which is what an SDK user would quote at you; - a URI, with or without a leading method and with or without a leading slash —
POST /api/invoices,post api/invoices,/api/invoices,api/invoices. A base path your document shares is added or taken away as needed, soinvoicesfinds/api/invoices.
Nothing matches exactly? The argument is matched as a fragment of any of the three instead, which turns a failed lookup into a menu:
php artisan docuccino:explain invoices3 operations match "invoices".──────────────────────────────
Method URI Document Route Operation id ────── ─────────────────────── ──────── ────────────── ──────────── GET /api/invoices default invoices.index listInvoices POST /api/invoices default invoices.store storeInvoice GET /api/invoices/{invoice} default invoices.show showInvoice
php artisan docuccino:explain "GET /api/invoices"--method narrows a URI several verbs answer, so docuccino:explain api/invoices --method=post and
docuccino:explain "POST /api/invoices" are the same request. It is only needed to disambiguate —
a URI one verb answers never asks for it.
Several matches is an answer, not an error: the command lists them and exits 2 rather than
picking one for you. Nothing matching at all exits 1 and prints the spellings it accepts, filled in
with an operation your document really has.
| Flag | Values / default | Effect |
|---|---|---|
route (required) |
route name | operation id | URI | fragment | Which operation to explain. No match → exit 1; several → exit 2. |
document |
configured key / all documents | Which document(s) to search. Every configured document is built and searched when omitted, and the answer always names the one it is about. Unknown key → exit 1. |
--method |
get | post | put | patch | delete | … / unset |
Narrows a URI several verbs answer. An invalid value errors (no silent fallback). |
--field |
field name | node.field path | fragment / unset |
Explains one field with every value printed in full. No match → exit 1; several → exit 2. |
--json |
flag / off | Prints the whole trail as JSON — the same status / exit-code pair, plus nodes[] carrying each field’s contributions with their layer, rank, value, source and confidence. |
--memory-limit |
php.ini value, e.g. 2G / unset |
Raises the process memory limit; the document is generated first, so it needs export’s headroom. |
Why it never needs --provenance
Section titled “Why it never needs --provenance”--provenance is an export setting: it decides how much of the trail survives
into a committed artifact, and winners — the default — drops the overrode records that say what
was shadowed. docuccino:explain never reads an artifact. It builds the document in memory, where the
trail is always complete, so the shadowed half is there whatever your export settings are.
That also means an operation the report finds nothing for really did record nothing, rather than having had it stripped: it is a skeleton, for an action Docuccino could not reflect. The command says so instead of printing an empty report.
What it deliberately doesn’t do
Section titled “What it deliberately doesn’t do”The trail describes this build, not its history. Docuccino keeps git metadata out of the document
on purpose — a commit SHA in the output would break byte-identical builds — so there is nothing here
about when a value changed, or who changed it. For “what changed”, commit the artifact and use
docuccino:diff.
A shadowed contribution is also recorded by producer alone: overrode keeps the field, the value that
lost and the producer that wrote it, and has nowhere to record the file it came from. The report says
so once at the bottom rather than leaving an empty column on every ✗ row.
docuccino:version-changes
Section titled “docuccino:version-changes”Scaffold the version-change classes for the differences between a published version and the current build.
docuccino:version-changes {old : Path to the committed artifact of the version this one diverges from} {document? : The configured document key to build as the new side (defaults to "default")} {--against= : Read `old` from this git ref (git show <ref>:<old>) instead of the working tree} {--since= : The version the scaffolded changes shipped in (defaults to the document's info.version)} {--in= : Write every class into this configured api_version.changes directory, whatever owns it} {--dry-run : Report what would be written, and write nothing} {--memory-limit= : Raise the PHP memory limit for inference (e.g. 2G)}| Flag | Values / default | Effect |
|---|---|---|
--against |
git ref / unset | Reads old with git show <ref>:<old> instead of off disk, so the path must be repo-relative. Same reader as docuccino:diff. |
--since |
a version / the document’s info.version |
The version the scaffolded changes shipped in. The code is always the newest version, so this is the version you are cutting. |
--in |
one of the configured directories / unset | Writes every class into this one directory, overriding the module each would otherwise go beside. Given as you wrote it in config, or absolutely. |
--dry-run |
flag / off | Prints the same report and writes nothing. |
--memory-limit |
php.ini value, e.g. 2G / unset |
See the shared-behavior note above. |
Reads the document the previous version published, builds the current one, diffs the two over stable
identities — the same read and the same differ docuccino:diff uses — and writes a
version-change class for each difference the vocabulary expresses.
That is what makes declaring a version nearly free: the target and the mechanics come off the diff, and
what you write is the sentence.
The description is a first draft, not a TODO. Each scaffolded class carries the diff’s own
factual sentence — “FormData publishes title where it published name.” — because a consumer
deciding whether the upgrade touches them needs that sentence, and a placeholder would ship as one. What
it cannot know is why the change was made and whom it affects, so the command says so and leaves that
half to you.
#[AppliesTo] is emitted only when the change really is partial. A shared component has one
shape, so a component that changed changed for every operation still publishing it — scoping such a
change would fork the ones it named and leave the rest at today’s shape, which is the document-wide
rewrite a scope exists to prevent, in reverse. So the scope is written only where your application
forked the shape: an operation that already published today’s shape in the older version, because it
pointed somewhere else then. Operations are named one at a time, never collapsed into a * — a selector
matching one operation more than intended widens the change silently. Where one of them cannot be named
safely, nothing is written for that schema and the reason is printed: an incomplete version you can see
costs you less than a complete one that lies.
It writes only what it can say truthfully. Seven differences have verbs — a renamed response,
request or parameter name; a removed or newly-required response field; and a response or request field
that became optional — and everything else is printed under Not declared with nothing written for it.
A field or a parameter a version added needs no declaration (older documents simply don’t accept it);
a removed request field, a request field that became required, and a type change have no honest verb;
a parameter that went with nothing wearing its shape arriving beside it is no rename anyone can read;
and a schema no class produces cannot be named by one. A wrong declaration would put a shape nobody
served into every older document, which is worse than a gap you can see.
Each class is written beside the module that owns it. When
api_version.changes contains a glob, the wildcard is
where you declared your boundary — modules/*/Api/Versions says a module is the unit — so a change is
written into the directory whose module holds the class its verb names:
Written InvoiceResourceTitleReplacesName — `InvoiceResource` publishes `title` where it published `name`. into modules/Billing/Api/Versions — beside modules/Billing, which owns Billing\Data\InvoiceResource. #[AppliesTo(operation: 'GET /api/invoices')]The destination and the reason are printed for every class, whether a module was found or not. The
rules, in order: --in overrides everything; otherwise the longest declared module root holding the
class’s own file wins; two roots holding it equally name no single module, so the change falls back and
says so; and a class no module holds — or a configuration with no glob in it at all — goes to the first
configured directory. A diff spanning two modules writes each change beside its own module, because a
change names exactly one class. A renamed parameter names no class at all, so it goes to the first
configured directory with the reason saying which of the two things happened.
An existing class is never touched. A file of that name is yours the moment it exists, and the command reports what it left alone rather than merging into it.
Determinism. Same two documents in, same bytes out: no timestamps, no absolute paths, class names derived from the schema and the field rather than from a counter, and the classes written in name order.
Customising the generated class
Section titled “Customising the generated class”The template is a stub, published the way every other publishable file is:
php artisan vendor:publish --tag=docuccino-stubsThat writes stubs/docuccino/version-change.stub, which the command prefers whenever it is there — and
deleting it puts the packaged one back. There is no config key for it: the file being present is the
statement that you want yours. The report says which stub it used.
These placeholders are filled in; both spellings work, as in Laravel’s own stubs:
| Placeholder | Filled with |
|---|---|
{{ namespace }} |
The namespace for the directory being written to, derived from your composer.json PSR-4 map |
{{ class }} |
The class name, derived from the schema, the field and what happened to it |
{{ since }} |
The version from --since, escaped for a single-quoted PHP string |
{{ description }} |
The factual sentence, escaped for a single-quoted PHP string |
{{ imports }} |
The use lines, one per line: #[ApiVersionChange], the verb, and the classes the verb names |
{{ verbs }} |
The attributes the change declares, one per line: any #[AppliesTo] scope, then the verb — all with named arguments |
The namespace is derived rather than asked for, and a directory no PSR-4 prefix covers is refused: a
change class is found by scanning source and then loading it, so one your autoloader cannot map would
never be applied — silently. Map the directory in composer.json and run the command again.
Exit codes
Section titled “Exit codes”Every command returns 0 on success and 1 on failure, and docuccino:explain also returns 2.
What counts as failure:
| Command | Exits 1 when |
|---|---|
install |
disabled; a configuration file could not be written; the first export failed |
export |
disabled; unknown --format, --fail-on or --provenance value; --out given while exporting multiple documents, or without --format against a multi-target document; unknown document key; an export.targets list it cannot read, or a routes.filter it cannot apply; an artifact it wrote is not a valid document of its own format (regardless of --fail-on); an unaccepted diagnostic matches --fail-on |
validate |
disabled; unknown --fail-on value; unknown document key; an export.targets list it cannot read, or a routes.filter it cannot apply; either schema violation — the built document’s or an artifact’s (regardless of --fail-on, and never acceptable — it’s an error); an unaccepted diagnostic matches --fail-on |
diff |
disabled; unknown document key; old missing, unreadable or not valid JSON; git show fails; a ref or path starting with -; the two documents are incomparable; --enforce with an unsatisfied verdict |
cache |
disabled; unknown document key; the payload is not a valid document of its own format — cached anyway, so the viewer still has something |
clear |
unknown document key (no enabled guard) |
watch |
disabled; unknown document key; --interval that isn’t a positive number; no documents configured. A failing rebuild does not stop the session — it prints and waits for the next change |
coverage |
disabled; unknown document key; --min outside 0–100; a merge that is incomplete (a directory missing, one holding no log, or a log that isn’t one); no artifact to measure against, or one that isn’t JSON; coverage below --min |
explain |
disabled; unknown document key; unknown --method value; no operation matches the query; no field matches --field. Exits 2 — not 1 — when several operations or several fields match, so a script can tell “not found” from “be more specific” |
version-changes |
disabled; unknown document key; old missing, unreadable or not valid JSON; git show fails; the two documents are incomparable; no version to scaffold against; the document configures no change directory; --in names none of them; no PSR-4 prefix covers the target directory; the stub could not be read, or a class could not be written |