Example payloads
A schema says what a field is. An example says what a real call looks like — and it’s the part of your documentation a reader pastes into their client. Inference fills in example values where the code states them outright, and synthesizes one for every request property whose validation rules pin a value; everything else on this page is how you supply the rest, either by writing one or by publishing the ones your tests already produce.
On a response
Section titled “On a response”Where inference can’t derive an example — and for the payloads you actually want a reader to copy —
#[Example] pins them. One declaration sets the response’s
single example; several named ones publish a map a viewer offers as a picker, so an endpoint can
show what a settled invoice looks like beside an overdue one.
#[Response(status: 200, type: InvoiceResource::class, description: 'The invoice')]#[Example(name: 'paid', summary: 'A settled invoice', value: ['id' => 42, 'status' => 'paid'])]#[Example(name: 'overdue', summary: 'One past its due date', value: ['id' => 43, 'status' => 'overdue'])]public function show(Invoice $invoice): InvoiceResource { /* … */ }"content": { "application/json": { "schema": { "$ref": "#/components/schemas/InvoiceResource" }, "examples": { "overdue": { "summary": "One past its due date", "value": { "id": 43, "status": "overdue" } }, "paid": { "summary": "A settled invoice", "value": { "id": 42, "status": "paid" } } } }}The map is sorted by name, so adding an example never moves the ones already there and a diff shows only what you changed.
On a request body or a parameter
Section titled “On a request body or a parameter”A field’s example: on #[BodyParameter] or
#[QueryParameter] illustrates that one field.
#[Example] illustrates the whole thing — the request body as a reader would send it, or one
parameter — and several named declarations publish a map here too.
#[Example(name: 'minimal', summary: 'The two required fields', request: true, value: [ 'name' => 'Acme Ltd', 'currency' => 'GBP',])]#[Example(name: 'with-terms', request: true, file: 'docs/examples/customer-with-terms.json')]#[Example(name: 'second-page', parameter: 'page', value: 2)]public function store(StoreCustomerRequest $request): CustomerResource { /* … */ }"requestBody": { "required": true, "content": { "application/json": { "schema": { "type": "object", "properties": { "name": { "type": "string" } } }, "examples": { "minimal": { "summary": "The two required fields", "value": { "name": "Acme Ltd", "currency": "GBP" } }, "with-terms": { "value": { "name": "Acme Ltd", "currency": "GBP", "net_days": 30 } } } } }}request: true targets the request body and parameter: names a parameter — path, query, header or
cookie, whichever it turns out to be. The full argument list is in the attribute
reference.
What sits above a synthesized example
Section titled “What sits above a synthesized example”Request properties come with an example already, derived from their own validation rules and checked against their own schema before it is published — the rules reference has the table and the cases that get nothing. It sits at the bottom of the ladder, so everything on this page displaces it:
| The example on a request property comes from | Which beats |
|---|---|
| the rules, synthesized | nothing — it is the floor |
a #[RuleSchema(example: …)] on your own rule class |
the synthesized one, wherever that rule applies |
a #[BodyParameter(example: …)] / #[QueryParameter(example: …)] |
both of the above, for that one property |
An #[Example] is a different node — the body or the parameter as a whole, not one property — so it
never competes with these. A reader gets both: the payload you curated, and per-field values inside the
schema for the fields it doesn’t mention.
Nothing synthesized ever silently replaces something you wrote, and nothing you write is held to the schema by the synthesis — contract testing is what checks an example you wrote yourself.
Examples from a file
Section titled “Examples from a file”A realistic payload is a poor fit for an attribute argument. Point file: at a .json, .yaml or
.yml file relative to your application root and Docuccino reads it in:
#[Example(name: 'full-cart', file: 'docs/examples/full-cart.json', summary: 'Three lines and a discount')]public function show(Cart $cart): CartResource { /* … */ }The file joins that endpoint’s build dependencies, so editing it regenerates the endpoint — including when you create a file that wasn’t there yet. A file Docuccino can’t read raises a diagnostic and the example is left out; nothing partial is ever published.
Illustrating something other than the success response
Section titled “Illustrating something other than the success response”By default an example illustrates the success response — the lowest 2xx the operation documents — in
that response’s first media type. status: sends it to another response, and mediaType: to another
content type:
#[Response(status: 404, type: ProblemDetails::class, description: 'No such invoice')]#[Example(name: 'not-found', status: 404, value: ['title' => 'Not Found', 'status' => 404])]public function show(Invoice $invoice): InvoiceResource { /* … */ }Where that error body is one other endpoints return too, it is
hoisted into a shared component,
and your name goes with it: the shared response publishes not-found beside the keys generated for
endpoints that illustrated the same body without naming anything. Annotating one endpoint never changes
what another publishes.
Examples your tests recorded
Section titled “Examples your tests recorded”Your suite produces real payloads all day. Recording them gives your document examples with real data in them — and the build still runs none of your endpoints, because the running already happened, in your tests.
-
Say where recordings live in
docuccino.yaml, and commit that directory:documents:default:examples:recordings: 'docs/recordings' -
Turn the recorder on in the same bootstrap you registered the contract-testing macros in:
\Docuccino\Laravel\Testing\ApiContract::record(); -
Name the responses worth publishing, one assertion at a time:
$this->getJson('/api/invoices/42')->assertOk()->assertValidExchange(); // checked$this->getJson('/api/invoices/42')->assertOk()->assertValidExchange(recordAs: 'paid'); // checked and published -
Run your suite, then read the diff:
Terminal window php artisan testgit diff docs/recordings
Recording is a deliberate act
Section titled “Recording is a deliberate act”recordAs: is not decoration on top of a recorder that was already going to publish something. It is
the whole of what asks for a response to be published, and an assertion without it checks the response
and records none of it.
The two decisions pull in opposite directions. Checking wants every exchange your suite can throw at
the contract, because that is how a defect gets found — a suite with hundreds of assertValidExchange()
calls and no recordings is a suite doing its job. Publishing wants one response per operation,
chosen by someone who knows which one a reader should see. Tie the two together and the second decision
gets made by whichever test happened to answer with the highest-ranking body — which is how a factory’s
"key": "qui-et-voluptatibus" ends up as the illustration of an endpoint, and how a nullable column
that is sometimes null rewrites a committed file on alternate runs.
So the endpoints you want illustrated get a name, the rest get checked, and what your document shows is something you chose.
You get one file per operation, named after its stable x-docuccino.id — so renaming a route carries
its recordings with it:
{ "docuccino": "recording/1", "operation": "op:v1:kgfeda3prpqaw73i", "endpoint": "GET /api/invoices/{invoice}", "responses": [ { "status": "200", "mediaType": "application/json", "body": { "id": 42, "reference": "INV-2026-0042", "total": 149.5, "status": "paid" } } ]}"200": { "description": "OK", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Invoice" }, "example": { "id": 42, "reference": "INV-2026-0042", "total": 149.5, "status": "paid" } } }}The curating is yours, and it happens in the diff
Section titled “The curating is yours, and it happens in the diff”You choose which responses are candidates at all, by naming them. Where several tests share one name, the recorder narrows those down to one body per status, media type and name, and then hands it to the only reviewer who can judge it — you, reading a diff, before the file is committed.
Three rules do the narrowing, and each of them exists so the file is a function of your responses rather than of the run that produced them:
| Rule | Why |
|---|---|
| Only an exchange whose response was checked and passed is recorded | A body that contradicts its own schema can never become the illustration of it |
| The published body is the one that fills in the most of the shape — then the shorter, then the smaller by the bytes it would publish | Reordering your tests never changes what the document shows, and the example a reader copies is the one with the most of the contract in it |
| A committed body is left byte-identical while its shape is unchanged | A created_at, a UUID or an autoincrement key moves on every run and the structure on none of them, so re-recording an unchanged suite rewrites nothing |
That last one is what keeps your artifact stable. Re-record as often as you like: the file changes when
the shape of a response changes, which is a change worth reading, and never because a fixture had a
different id this morning. A response keyed by ids is stable on the same terms — a map under
"0193a1f0-…" holds one kind of thing rather than a member of that name, so which ids it holds never
moves the shape, while how many of them it holds still does. And because every one of the three reads
the responses alone, several test workers can record one operation at once without any of them having
to know about the others.
Credentials never reach the file
Section titled “Credentials never reach the file”A response captured from a live request can hold a token, a key, or a customer’s details, and a document is a thing people publish. So the recorder replaces credentials on the way out, before anything is written:
- any string under a member name that means a secret —
password,api_key,secret,token,remember_token,ssn,cvvand the rest of the leakage heuristics — including everything nested beneath it; - any string that is a recognizable credential whatever it’s called: a PEM private key, an AWS key id, a GitHub or Slack token, a live Stripe key, a JWT, a URL with a password in it.
Each becomes "[redacted]", and only strings are touched — a token_count of 5 stays the integer
5, so the example goes on satisfying its own schema. Your own lint.leakage.patterns apply here too:
teach Docuccino a sensitive member name once and both the lint and the recorder learn it.
A number called a credential — "cvv": 123, a numeric card number — is a secret too, and it can’t be
replaced without making the example contradict its own schema. So it is reported instead: the build
refuses to publish that body at all and tells you where it is, with the same
examples.recording-unsafe warning. Nothing but the name can speak for a number, so there the name has
to be one of the heuristics rather than contain one — token counts, token_count doesn’t.
Half of lint.leakage.allow applies, and only half. A JSON pointer into the response body —
'/meta/next_page_token' — publishes that one value, because it is a statement about that value. A bare
property name silences the lint and is ignored here: it would exempt every member of that name in
every body, which is the difference between accepting one public value and publishing whatever a live
request happened to return.
The build checks again when it reads the file. A committed body that still looks like it holds a
credential — hand-edited, or caught by a heuristic added since — is not published at all, and says
so with an examples.recording-unsafe warning naming the pointer (never the value).
Name the scenarios worth showing
Section titled “Name the scenarios worth showing”A suite that exercises an empty cart, a full one and a locked one has three payloads worth publishing. Name them at the call site — the test already knows which scenario it set up:
$this->getJson('/api/carts/1')->assertValidResponse(recordAs: 'empty-cart');$this->getJson('/api/carts/2')->assertValidResponse(recordAs: 'full-cart');Named recordings publish together as the media type’s examples map, one entry per name, sorted by
name so adding a scenario never moves another:
"content": { "application/json": { "schema": { "$ref": "#/components/schemas/Cart" }, "examples": { "empty-cart": { "value": { "items": [], "total": 0 } }, "full-cart": { "value": { "items": [{ "sku": "COG-1", "qty": 2 }], "total": 49.5 } } } }}A name may hold letters, digits, dots, dashes and underscores — it becomes a key a generated client reads, so the assertion refuses anything else at the line that wrote it. Where several tests share one name, the best body of them is published, decided by the three rules and never by which test ran first. Taking a name back out of your tests is the one change re-recording can’t make for you — a recorded name is never deleted on the strength of a run that might have been a partial one, so delete the file and record it again.
The name is always yours. Docuccino will not derive one from the test’s name, because renaming a test would then rename a published example — a change to your contract that nobody asked for.
Where a recording sits against everything else
Section titled “Where a recording sits against everything else”A recorded example sits at the integration(20) rung of the
precedence ladder. A recording is evidence — your
application really did answer this — so it beats a shape inference merely derived. But an
#[Example] is you choosing what a reader should see, and that is never overruled by a test fixture. A
name you passed at a call site is a choice, though, so a named recording can sit beside the examples
you curated:
| You wrote | Named recordings |
|---|---|
| nothing | publish as an examples map of their names |
a singular #[Example] |
yours, alone |
named #[Example]s |
yours plus the recorded names — yours wins any name you both spell |
A singular example is the one place a recording has nowhere to go: filing it beside yours would mean inventing a name for yours, which is not Docuccino’s to choose.
A recording is also only ever an illustration. A status or media type your document doesn’t describe gets no example from one — a recording never adds a response the contract doesn’t state.
When recordings go stale
Section titled “When recordings go stale”Every build reports what is wrong with the committed directory. Every code below is in the diagnostics reference too, alongside what to do about each one.
| Code | Severity | Means |
|---|---|---|
examples.recordings-empty |
info | The directory you configured holds no recordings yet |
examples.recordings-escapes-base |
warning | The directory you configured isn’t inside your application, so it was never read |
examples.recording-unreadable |
warning | A file in it isn’t a recording Docuccino can read |
examples.recording-orphaned |
warning | A recording for an operation this document no longer has — the route was renamed, moved or removed |
examples.recording-unsafe |
warning | A committed body still holds what looks like a credential; it wasn’t published |
examples.recording-unnamed |
info | A committed body no assertion named — from before recording was opt-in. It still publishes, and no run will refresh it |