Skip to content

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.

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 { /* … */ }

The map is sorted by name, so adding an example never moves the ones already there and a diff shows only what you changed.

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 { /* … */ }

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.

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.

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.

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.

  1. Say where recordings live in docuccino.yaml, and commit that directory:

    documents:
    default:
    examples:
    recordings: 'docs/recordings'
  2. Turn the recorder on in the same bootstrap you registered the contract-testing macros in:

    \Docuccino\Laravel\Testing\ApiContract::record();
  3. 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
  4. Run your suite, then read the diff:

    Terminal window
    php artisan test
    git diff docs/recordings

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" }
}
]
}

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.

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, cvv and 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).

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.

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