Return-type inference
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.
Status codes
response()->json($x, 201) folds the payload under 201.Empty responses
204, 205, 304, or 1xx is documented with no body.Files and streams
Headers & overrides
From your return type
Section titled “From your return type”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()(nototal/last_page),cursorPaginate(), or the Spatie JSON API Paginate package’sjsonPaginate().
public function show(Invoice $invoice): InvoiceResource{ return new InvoiceResource($invoice);}"responses": { "200": { "description": "OK", "content": { "application/json": { "schema": { "type": "object", "properties": { "data": { "$ref": "#/components/schemas/InvoiceResource" } }, "required": ["data"] } } } }}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.
Union return types
Section titled “Union return types”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.
public function show(Invoice $invoice): InvoiceResource|ArchivedInvoiceResource{ return $invoice->trashed() ? new ArchivedInvoiceResource($invoice) : new InvoiceResource($invoice);}"schema": { "anyOf": [ { "type": "object", "properties": { "data": { "$ref": "#/components/schemas/InvoiceResource" } }, "required": ["data"] }, { "type": "object", "properties": { "data": { "$ref": "#/components/schemas/ArchivedInvoiceResource" } }, "required": ["data"] } ]}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.
Pagination
Section titled “Pagination”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.
Media types
Section titled “Media types”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.
Status codes and JsonResponse
Section titled “Status codes and JsonResponse”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]);}"200": { "description": "OK", "content": { "application/json": { "schema": { "type": "object", "properties": { "id": { "type": "integer" } }, "required": ["id"] } } }},"202": { "description": "Accepted", "content": { "application/json": { "schema": { "type": "object", "properties": { "status": { "type": "string", "const": "accepted" } }, "required": ["status"] } } }}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.
Automatic 201 Created
Section titled “Automatic 201 Created”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.
Spatie Data success status
Section titled “Spatie Data success status”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.
Empty responses
Section titled “Empty responses”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 bodyreturn response()->json(null, 204); // 204, no bodyreturn response()->json(['deleted' => 1], 204); // 204, no body — that payload never reaches a clientThe 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);}"204": { "description": "No Content"}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.
Framework response objects
Section titled “Framework response objects”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.
Responses that aren’t JSON
Section titled “Responses that aren’t JSON”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.
HTML and view responses
Section titled “HTML and view responses”An action that renders a Blade template is documented as a 200 with a text/html body:
public function printable(Invoice $invoice): View{ return view('invoices.printable', ['invoice' => $invoice]);}"responses": { "200": { "description": "OK", "content": { "text/html": { "schema": { "type": "string" } } } }}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.
File downloads and streams
Section titled “File downloads and streams”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.
public function download(Invoice $invoice): BinaryFileResponse{ return response()->download(storage_path("app/invoices/{$invoice->id}.pdf"), 'invoice.pdf');}"responses": { "200": { "description": "OK", "headers": { "Content-Disposition": { "description": "Asks the client to save the body as a file rather than display it.", "schema": { "type": "string" }, "example": "attachment; filename=\"invoice.pdf\"" } }, "content": { "application/pdf": { "schema": { "type": "string", "format": "binary" } } } }}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.
Where the media type comes from
Section titled “Where the media type comes from”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:
- a literal
Content-Typeyou pass in the call’s$headersargument; - the media type the call fixes itself —
eventStream()always sendstext/event-stream,streamJson()always sendsapplication/json; - 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; - otherwise
application/octet-streamfor 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']);Downloads and inline files
Section titled “Downloads and inline files”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.
Server-sent events
Section titled “Server-sent events”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.
Adding and refining responses
Section titled “Adding and refining responses”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 { /* … */ }