Skip to content

Responses

Docuccino documents an endpoint’s success responses from the way your action returns — its return type and return statements. You write the code you’d write anyway; the response shapes follow.

Return-type inference

A resource, model, or enum return becomes its schema.

Status codes

response()->json($x, 201) folds the payload under 201.

Empty responses

A 204, 205, 304, or 1xx is documented with no body.

Files and streams

A download, a stream, or an event stream gets its own media type.

Headers & overrides

Add headers and extra statuses with attributes.

Whatever an action returns is documented as its 200 body. Docuccino resolves the concrete shape through the same inference that powers everything else:

  • an API Resource becomes its resource schema;
  • an Eloquent model becomes its model schema;
  • a backed enum becomes its enum schema;
  • a resource collection becomes an array of that schema, wrapped in the { data, links, meta } pagination envelope when the collection paginates — paginate() (length-aware), simplePaginate() (no total/last_page), cursorPaginate(), or the Spatie JSON API Paginate package’s jsonPaginate().
app/Http/Controllers/InvoiceController.php
public function show(Invoice $invoice): InvoiceResource
{
return new InvoiceResource($invoice);
}

A top-level resource is wrapped under data (Laravel’s default $wrap); the InvoiceResource component it references is defined once and shared. Control or disable the wrapping with the api_resources.wrap option — see resource wrapping.

Each response’s description is the standard reason phrase for its status — 200 OK, 201 Created, 202 Accepted, 204 No Content, and so on — so you don’t hand-write them. Responses are emitted in ascending status order, whatever order your return paths appear in.

An action whose return type has more than one arm documents as anyOf, one branch per arm — and each branch keeps the envelope that arm would carry on its own. That’s what the server sends: the resource actually returned is the one whose wrapping applies, so the data key belongs inside each branch rather than around the union.

app/Http/Controllers/InvoiceController.php
public function show(Invoice $invoice): InvoiceResource|ArchivedInvoiceResource
{
return $invoice->trashed()
? new ArchivedInvoiceResource($invoice)
: new InvoiceResource($invoice);
}

Per-branch is what keeps the document true when the arms disagree. A Spatie Data class with its own defaultWrap() keeps its own key while the other arm keeps data, and an arm that strips its envelope — the withoutWrapping() a problem document needs to sit at the root — stays bare beside a wrapped sibling. A single outer wrapper would have to pick one of them and be wrong about the rest.

A nullable return is the same rule with a null arm: ?InvoiceResource documents { "type": ["object", "null"], "properties": { "data": … }, "required": ["data"] }, the envelope intact and null folded into its type.

A paginated collection’s { data, links, meta } envelope is defined once, as a component named after the item type and the paginator you used, and every endpoint returning that page points at it:

"responses": {
"200": {
"description": "OK",
"content": {
"application/json": { "schema": { "$ref": "#/components/schemas/InvoiceResourcePage" } }
}
}
}
"InvoiceResourcePage": {
"description": "One page of results, with links to the pages around it and totals for the whole result set.",
"type": "object",
"properties": {
"data": { "type": "array", "items": { "$ref": "#/components/schemas/InvoiceResource" } },
"links": { "$ref": "#/components/schemas/PaginationLinks" },
"meta": { "$ref": "#/components/schemas/PaginationMeta" }
},
"required": ["data", "links", "meta"]
}

Only data is spelled out per item type, because OpenAPI has no generics — there is no way to say “a page of T”, so a page of invoices and a page of contacts have to be two components. Everything else in the envelope is a function of the paginator and not of what you paginated, so it is one component the pages point at:

"PaginationLinks": {
"description": "URLs for the first, last, previous and next pages of this result set; null where there is no such page.",
"type": "object",
"properties": {
"first": { "type": ["string", "null"] },
"last": { "type": ["string", "null"] },
"next": { "type": ["string", "null"] },
"prev": { "type": ["string", "null"] }
}
},
"PaginationMeta": {
"description": "Where this page sits in the result set: the page number and size, the number of the last page, the record total, the index of the first and last record on this page, and the base URL its page links are built from.",
"type": "object",
"properties": {
"current_page": { "type": "integer" },
"from": { "type": ["integer", "null"] },
"last_page": { "type": "integer" },
"path": { "type": ["string", "null"] },
"per_page": { "type": "integer" },
"to": { "type": ["integer", "null"] },
"total": { "type": "integer" }
}
}

The page is a flat object of $refs rather than an allOf of a shared base — generators handle allOf unevenly, and it makes the one member that really is per-type harder to express, not easier.

There is no class in your application behind any of these components, so there is nothing for you to annotate — Docuccino writes the description instead. Everyone reading your document gets a sentence saying what a page holds and what each of its members is for, and a generated client carries it onto the type. If you want different wording, an overlay replaces it.

simplePaginate() gives you InvoiceResourceSimplePage and cursorPaginate() gives you InvoiceResourceCursorPage, because those pages carry different members — a simple page has no total, a cursor page has tokens instead of page numbers. Their envelope members are named for the shapes they are: a simple page points at SimplePaginationLinks/SimplePaginationMeta, a cursor page at CursorPaginationMeta — and back at PaginationLinks, since a cursor page’s links are the same four. Two endpoints paginating the same resource the same way share one component, so a generated client gets one page type per resource rather than one per endpoint.

Names come from the item type, the paginator and the shape alone, so adding or removing an unrelated endpoint never renames one. Where the item type can’t be identified — an untyped collection generic, a resource the analyzer can’t read — the envelope is written out on the endpoint instead of being named after a guess, though its links and meta still point at the shared components. Set representation.pagination.components to false to write all of it out everywhere.

A Spatie Data paginated collection has its own envelope — links is an array of { url, label, active } objects and meta carries *_page_url members — so it names its own: PaginationLink for the link object, and DataPaginationMeta/DataCursorPaginationMeta for the two metas. Those describe themselves the same way.

A response is application/json unless the payload itself says otherwise. A first-party JSON:API resource (or a collection of one) serializes as application/vnd.api+json, as does a timacdonald/json-api resource, a rendered view serializes as text/html, and a file or stream serializes as the type the call names.

An action that negotiates between representations gets one content entry per media type on the same status, rather than one schema filed under a media type half of it contradicts. The entries are ordered by media type, so adding a return path never reshuffles the ones already there.

Return response()->json($payload, $status) and Docuccino folds the payload shape under the status you passed — the JsonResponse wrapper itself is never rendered as a generic object. Distinct return paths with distinct statuses become distinct responses on the same operation:

public function store(StoreInvoiceRequest $request): JsonResponse
{
$invoice = Invoice::create($request->validated());
return response()->json(new InvoiceResource($invoice), 201); // → 201 with the resource body
}

Two return paths with two statuses become two responses:

public function queue(QueueReportRequest $request): JsonResponse
{
if ($request->boolean('async')) {
return response()->json(['status' => 'accepted'], 202);
}
return response()->json(['id' => $this->reports->build($request)->id]);
}

Literal values in the payload survive as JSON Schema consts, so 'accepted' is documented as the only value that status returns. Two return paths that share a status contribute one response whose schema is the union of their shapes.

A status computed at runtime (not a literal) falls back to 200; pin it with #[Response] when you need an exact code.

Returning a resource wrapped directly around a freshly created model — new InvoiceResource(Invoice::create(...)), InvoiceResource::make(Invoice::create(...)), or the same around Invoice::forceCreate(...) — is documented as 201 Created automatically, matching Laravel’s own behavior: those factory methods set wasRecentlyCreated, so ResourceResponse::calculateStatus() answers 201. Only that direct create-wrap is detected; create into a variable first, or use #[Response], when you need the code to be explicit. An explicit 201 always wins.

A Spatie Data class returned from a POST documents 201 Created, from Spatie’s own ResponsableData default; any other verb documents 200. A class that overrides calculateResponseStatus() documents that instead — return 201;, a class constant like Response::HTTP_CREATED, or a conditional whose arms are all constants (return $invoice->isPaid() ? 200 : 202; documents the same body under each status). A computed status falls back to 200 with a spatie-data.response-status-unresolved info diagnostic rather than guessing.

When that conditional is a decision the route already settles (return $request->routeIs('*invoices.store') ? 201 : 200;), each endpoint documents only the status it can actually answer with, so a GET never carries a 201 the server can’t send. Every other condition keeps each folded status documented, because on that endpoint each of them really can happen.

The 201 is new in v0.2: earlier releases read only the override. Spatie Data → Success statuses has the upgrade note, the full example and the shapes that narrow.

HTTP forbids a body on 204, 205, 304, and every 1xx status, so Docuccino documents a response under one of those with no content — whatever the action hands back:

return response()->noContent(); // 204, no body
return response()->json(null, 204); // 204, no body
return response()->json(['deleted' => 1], 204); // 204, no body — that payload never reaches a client

The last line is the one worth knowing about. The spec describes what goes over the wire, not what the code passes, so a payload aimed at a bodyless status is left out rather than documented as a body your consumers will never receive:

public function destroy(Invoice $invoice): JsonResponse
{
$invoice->delete();
return response()->json(['deleted' => true], 204);
}

The status keeps its reason phrase, and a schema dropped this way is never hoisted into components either — so you don’t end up with an orphaned component nothing references. The rule is about the status alone: response()->json(null, 200) really does send null, so that 200 is still documented as { "type": "null" }.

Every producer goes through the same gate, so #[Response(status: 204, type: InvoiceResource::class)] documents the status and its description, not a body — and because that attribute is something you asked for rather than something Docuccino inferred, it also reports an attribute.body-on-bodyless-status warning so the drop never happens quietly. If you genuinely need content on a bodyless status — describing a proxy that rewrites it, say — an OpenAPI Overlay applies to the assembled document after the pipeline has finished, and writes what you tell it to.

A bare void or never return (with no JsonResponse wrapper) documents nothing on its own — annotate it with #[Response] if it should appear.

A response object — JsonResponse, RedirectResponse, Illuminate\Http\Response, Symfony’s BinaryFileResponse and StreamedResponse — is transport, not a body. Its public members are PHP internals (original, exception, headers) that no client ever receives, so Docuccino never reflects one into components.schemas. That holds wherever the type turns up: a bare return, one arm of a JsonResponse|RedirectResponse union, a property of a class being expanded. Your own subclass (class ApiResponse extends JsonResponse) is covered too.

What gets documented instead is only what the class itself proves:

Return type Documented as
RedirectResponse 3XX with a Location header and no body
JsonResponse with no recoverable payload 200 with an open application/json body
BinaryFileResponse, StreamedResponse 200 with a binary body — see file downloads and streams
Illuminate\Http\Response the status alone — neither media type nor shape is stated anywhere

The redirect gets the OAS 3XX range key rather than 302: Laravel’s RedirectResponse defaults to 302 but takes any 3xx (redirect()->to($url, 301), ->away($url, 307)), and nothing at the return site says which. Pin it with #[Response(status: 302)] when the endpoint always answers with one code, and declare each code where it answers with several — a declared 3xx retires the range, taking the Location header with it, so the document says exactly one thing about the redirect rather than a precise answer and a vague one side by side.

The lint.unpinned-redirect info diagnostic reads the finished document and reports either shape it should not find there: the range on its own, and the range still standing beside a code. An overlay is applied after the document is built, so naming the code there cannot retract the range the attribute does — remove the 3XX response in the same overlay.

A bare JsonResponse or Illuminate\Http\Response loses the body itself, so it is announced rather than silent: each raises an inferred-response.payload-unrecoverable info diagnostic naming the action. Naming the body with #[Response(type: …)] — or dropping the response with #[IgnoreResponse] — silences it, since both settle the fact it reports missing. A stream is the one that also needs mediaType:, because a media type is what it lost. The usual cause is a collaborator whose own return type is bare —

public function reset(Request $request): JsonResponse
{
return $this->gateway->exchange($request); // SsoGateway::exchange(): JsonResponse
}

— so build the payload where the analyzer can see it, or name it with #[Response(type: ResetResultData::class)]. A JsonResponse whose payload is recoverable is documented in full, exactly as above; the guard only ever refuses the wrapper, never a payload.

Plenty of endpoints answer with something else — a rendered page, a download, a stream. Each is documented from what the action returns, and the media type comes from the response itself rather than from a guess.

An action that renders a Blade template is documented as a 200 with a text/html body:

app/Http/Controllers/InvoiceController.php
public function printable(Invoice $invoice): View
{
return view('invoices.printable', ['invoice' => $invoice]);
}

The schema is a plain string and stays that way. Markup has no structure your code proves, so anything narrower would be invented — and a client generated against an invented shape fails at runtime. A view is transport in the same sense a response object is, so it never reaches components.schemas either; your own subclass of Laravel’s view, and an action declared as returning the View contract rather than the concrete class, are both covered.

Nothing about this is a degradation you can fix, so it raises no diagnostic.

An action that answers HTML or JSON depending on the client — a View|InvoiceResource return — gets both entries under 200, one per media type:

"content": {
"application/json": { "schema": { "$ref": "#/components/schemas/InvoiceResource" } },
"text/html": { "schema": { "type": "string" } }
}

That’s also what a Laravel Actions action defining htmlResponse() produces, alongside the JSON body its jsonResponse() or handle() returns.

response()->download(...) and response()->file(...) both return the same bare BinaryFileResponse, and stream(), streamDownload() and eventStream() all return the same bare StreamedResponse — so the return type can’t tell them apart. Docuccino reads the call instead, and documents what it proves.

app/Http/Controllers/InvoiceController.php
public function download(Invoice $invoice): BinaryFileResponse
{
return response()->download(storage_path("app/invoices/{$invoice->id}.pdf"), 'invoice.pdf');
}

The body is a string with format: binary — that’s what a generated client reads to hand you bytes or a stream instead of a decoded value. There is no structure below it to describe, so there is nothing narrower to say.

application/pdf above isn’t a guess about the filename: BinaryFileResponse labels the response from the file it serves, so a literal path’s extension is the same answer the server will reach. Docuccino resolves the media type in this order:

  1. a literal Content-Type you pass in the call’s $headers argument;
  2. the media type the call fixes itself — eventStream() always sends text/event-stream, streamJson() always sends application/json;
  3. a well-known extension on a literal file path — .pdf, .csv, .zip, .xlsx, and so on. Laravel’s path helpers (storage_path(), public_path(), …) are seen through;
  4. otherwise application/octet-stream for a body read from a file, which is the fallback the server itself sends when its own lookup comes up empty.

A body written by a callback — stream(), streamDownload() — is the one case with no honest fallback: nothing sets a Content-Type, so the framework ends up labeling it text/html. Docuccino documents it as the */* range and raises an inferred-response.payload-unrecoverable info diagnostic, because that one is worth fixing. What the stream lost is its media type, so that is what settles it — #[Response(type: …, mediaType: 'text/csv')] names both, and naming the media type retires the */* range rather than publishing it beside the type, since a range that accepts anything would subsume it. type: on its own leaves the media type as unstated as it found it, and the notice keeps saying so:

// Symfony labels this text/html, whatever the filename says.
return response()->streamDownload($export, 'ledger.csv');
// State it, and both the response and the document say text/csv.
return response()->streamDownload($export, 'ledger.csv', ['Content-Type' => 'text/csv']);

Content-Disposition is documented only where the call actually sets it:

Call Content-Disposition
response()->download($file) attachment
response()->file($file) none — Laravel passes no disposition, so the header is never sent
response()->streamDownload($callback, $name) attachment, and only when $name is given
Storage::download($path) attachment
Storage::response($path) inline
response()->stream($callback), response()->eventStream($callback) none

A literal fourth argument overrides it — response()->download($file, $name, [], 'inline') documents inline. Both facades and a specific disk work the same way, since the receiver is matched by type: Response::download(...), Storage::disk('s3')->download(...). If one action takes two paths that disagree — one attaches, the other displays — no header is documented, because either answer would describe the wrong path.

response()->eventStream(...) is documented as a 200 with a text/event-stream body:

"content": {
"text/event-stream": {
"schema": { "type": "string" }
}
}

The schema is the wire format, deliberately. An SSE body is a sequence of event:/data: frames that keeps going until the connection closes, and an OpenAPI response body is a single value — so a schema naming the object your generator yields would tell a consumer the body is one event, which is false for every stream that sends two. A client generated against that would parse the first frame and fail. string is the widest thing that stays true.

Where the code can’t say everything, attributes step in — field by field, so they patch inferred responses without discarding the rest:

Attribute Use it to…
#[Response] Add a status, or set the description, type or mediaType of one — repeatable for several statuses.
#[ResponseHeader] Document a response header on a given status (status: 200 by default), and say with required: true when the server always sends it.
#[IgnoreResponse] Drop an auto-inferred response by status code.
#[Example] Pin the payload a reader copies — see Example payloads.
#[Response(status: 200, type: InvoiceResource::class, description: 'The invoice')]
#[Response(status: 404, description: 'Invoice not found')]
#[ResponseHeader(name: 'X-RateLimit-Remaining', type: 'integer', description: 'Calls left', status: 200)]
public function update(int $id): InvoiceResource { /* … */ }