Error responses
Error responses are one of the most tedious parts of API docs to maintain by hand. Docuccino documents them for you by reading how your application actually handles exceptions, and falls back to sensible defaults for the framework’s built-in ones.
For each exception an endpoint can throw, Docuccino resolves a response through three tiers. The first tier with something to say wins, and your attributes, docblocks, overlays or config always override the result:
-
Your exception handling — the shape your code really returns, under the media type it really sends it as.
-
Framework defaults — Laravel’s stock JSON shapes for the exceptions it renders itself.
-
A generic fallback — any other exception with a known status gets
{ "message": string }under that status (500when the status can’t be determined), so an error is never simply undocumented.
Answering is not the same as filling in a body. Where Docuccino can see that your own handler renders an exception and cannot read what it renders it to, the tier that answers publishes the status and its reason phrase and leaves the body unsaid: a stock shape is a claim about what the framework sends, and your code has already refuted it. Framework defaults has the detail.
Docuccino has no error preset to turn on, and needs none: an application that answers
RFC 9457 application/problem+json is documented as answering
problem+json because its renderer says so, and one that answers something else is documented as that.
Your exception handling (automatic)
Section titled “Your exception handling (automatic)”Docuccino reads your app’s real error handling and documents the exact shape it produces. For each exception it looks, in order, for:
-
A
rendercallback registered inbootstrap/app.php(->withExceptions(...)) whose first-parameter type the exception matches — a closure (->render(fn (PaymentException $e) => …)), an invokable renderer (->render(new ProblemRenderer)), an[$object, 'method']pair, or a first-class callable. Callbacks are tried in registration order, which is Laravel’s own match order, so the documented shape is the one that actually runs. Callbacks registered by a service provider or a package are found too — Docuccino reflects the booted handler rather than scanningbootstrap/app.phpas text. -
The exception’s own
render()method. -
A
Responsableexception’stoResponse().
Each is analyzed with the thrown type in mind, so even a single catch-all handler that branches on
instanceof is documented correctly per exception. Status codes and response bodies are read
directly from the code:
public function render(Request $request): JsonResponse{ return response()->json([ 'message' => 'Payment is required to continue.', 'invoice_id' => $this->invoiceId, ], 402);}"402": { "description": "Error", "content": { "application/json": { "schema": { "type": "object", "properties": { "message": { "type": "string" }, "invoice_id": { "type": "integer" } } } } }}Throw this from an action and a 402 response with that body shape is documented. The description is
the RFC 9110 reason phrase for the status (401 is Unauthorized, 422 is Unprocessable Entity, and
so on); a status without a standard phrase — like 402 here — is documented with a generic Error. If a
handler is too dynamic to read statically, Docuccino records a diagnostic and publishes what it did read;
where it read nothing at all the response falls to the next tier, which answers with the status alone
wherever it can see a renderer of yours returning a response for that exception.
Each part of the response is read on its own. A handler whose body reads but whose status does not — a
status off $e->getCode(), an enum method, or an HttpException subclass that sets its own — still
publishes that body, under the status the framework classifies the exception as. Documenting the shape
your server actually sends matters more than the number it is filed under, and the number is the one every
other tier would have used anyway.
The same holds one step further down. When the body will not read but the content type does — you set
['Content-Type' => 'application/problem+json'] on the response and built the payload somewhere Docuccino
cannot follow — the error is documented under that content type with a schema that constrains nothing.
Publishing nothing would say the error returns no body, and borrowing a shape from a later tier would name
members your server never sends; “a body of this type, contents unknown” is the statement your code
supports. Clients lose type safety for that one error and no more, and the diagnostic names the callback
to make readable.
public function __invoke(ModelNotFoundException $e): JsonResponse{ // The label is a literal on the call; the payload comes back from a formatter // resolved at run time, so nothing static can say what shape it takes. return response()->json($this->format($e), 404, [ 'Content-Type' => 'application/problem+json', ]);}"404": { "description": "Not Found", "content": { "application/problem+json": { "schema": {} } }}An empty schema hoists nowhere, so nothing is minted into components for a shape nobody read.
Responses built through a helper
Section titled “Responses built through a helper”Real apps rarely build the response inline — a renderer usually branches on the exception type and calls a shared factory:
return match (true) { $e instanceof InvoiceNotFoundException => ProblemResponse::make('Not Found', 404, $e->getMessage()), $e instanceof ValidationException => ProblemResponse::validation($e->getMessage(), $errors), // …};
// The factory's return type erases the shape:public static function make(string $title, int $status, string $detail): JsonResponse { … }Docuccino follows that indirection. It reads each match/if arm, steps into the helper the arm
calls, and recovers the shape the helper actually builds — the payload keys, the status, and the media
type. A constant status passed to the helper (make('Not Found', 404, …)) is recovered as that literal;
a status the helper derives from a value it can’t see statically ($e->getStatusCode()) can’t be read,
so the response falls back to the status Docuccino inferred for the exception itself rather than
guessing. When the status comes from an enum case the arm names explicitly, Docuccino folds it — see
enum-backed problem types below.
An application/problem+json factory is documented under application/problem+json, whether the helper
passes the label as a constructor argument or writes it onto the response it returns:
$response->headers->set('Content-Type', 'application/problem+json');Only writes between the returned variable’s last assignment and the return are read, so a helper that
negotiates two branches into the same $response can’t lend one branch’s label to the other.
Because the literal values are recovered too, each response carries a concrete example alongside
the schema, and every member whose value is a constant is pinned with a JSON-Schema const:
$e instanceof AuthorizationException => ProblemResponse::make('https://errors.example/forbidden', 'Forbidden', 403, $e->getMessage()),"403": { "content": { "application/problem+json": { "schema": { "type": "object", "properties": { "type": { "type": "string", "const": "https://errors.example/forbidden" }, "title": { "type": "string", "const": "Forbidden" }, "status": { "type": "integer", "const": 403 }, "detail": { "type": "string" } }, "required": ["type", "title", "status", "detail"] }, "example": { "type": "https://errors.example/forbidden", "title": "Forbidden", "status": 403, "detail": "string" } } }}A status member that carries the response’s status code is filled with this response’s — the 403
arm says 403, a 404 arm says 404 — even when the status itself was computed dynamically.
Examples are completed, not withheld
Section titled “Examples are completed, not withheld”detail is $e->getMessage() — no static value, and required, so an example omitting it would fail
validation against the schema printed beside it. Docuccino fills it from the declared type instead. The
fill is confined to examples; nothing invented ever reaches a schema.
| Member | In the example |
|---|---|
Folded to a literal, or pinned by a const |
Its real value, verbatim. |
Carrying an example or a default of its own |
That value. Your own word for what a member looks like beats anything derived from its type. |
| Required, didn’t fold | Filled from the member’s own schema — see the ladder below. |
| Optional, and the code didn’t supply it | Left out — the example is this branch’s body, not the union of every branch’s. |
| Supplied by the code, optional in the schema | Included: being passed here is the stronger fact. |
| Optional, supplied, no type stated | Left out. "string" for what may well be a list would state what the code never said. |
| Required, no type stated | Filled with "string" anyway — dropping an otherwise-complete example is worse. |
A member is filled from every keyword that names a value, and its bare type is the last of them. The
schema printed beside the example is the honest half of what is known about that member, so the fill reads
all of it rather than the type alone — an enum illustrated "string" would be a body your server
refuses, and the example lint would then report it against an
example you never wrote and cannot correct:
| The member’s schema says | The example shows |
|---|---|
enum |
Its first entry — a list’s order is yours, and it’s the branch every other reader of the document shows. |
format |
The sample for that format, from the same table representation.examples.formats configures. |
A numeric bound — minimum, maximum, exclusiveMinimum, exclusiveMaximum, multipleOf |
A number those bounds admit. minimum: 5 shows 5, because 0 is a value that schema rejects. |
allOf |
The one schema its branches add up to, read again by these same rules — an intersection-typed member has no readable type of its own. |
| Only a type | "string", 0, true, an object’s own required members, or a one-element array built the same way. |
pattern and the length bounds are deliberately not read: they constrain a value without naming one,
and no constant satisfies an arbitrary regex. A member described that way is illustrated from its type, and
the example lint is what tells you the two disagree.
Nullable members are illustrated through their non-null branch, $refs are followed, and the fill
stops at a small depth so a self-referential problem document can’t unroll forever.
One refusal is deliberate: a constructor argument written as a credential-named constant
(self::SIGNING_KEY) is never folded, because a folded literal becomes a published example. Its
neighbors still fold, and the leakage lint covers values that
reach the document another way.
Enum-backed problem types
Section titled “Enum-backed problem types”Many apps keep problem metadata on an enum — one case per problem, with accessors for the URL, status and title — and pass a case into the helper:
enum ProblemType: string{ case Forbidden = 'https://errors.example/forbidden'; case NotFound = 'https://errors.example/not-found';
public function status(): int { return match ($this) { self::Forbidden => 403, self::NotFound => 404, }; }
public function title(): string { … }}
// The arm names a concrete case:$e instanceof AuthorizationException => ProblemResponse::make(ProblemType::Forbidden, $e->getMessage(), $request),$problemType->value → "https://errors.example/forbidden" (the case's backing value)$problemType->name → "Forbidden" (the case name)$problemType->status() → 403 (match ($this) arm for Forbidden) → also the response status$problemType->title() → "Forbidden" (match ($this) arm)Because the arm names the case, Docuccino resolves the accessors the helper applies to it — even across
the extra hop from a renderProblem($case) wrapper into the factory. ->value and ->name come
straight from the case (so they fold for vendor enums too). A no-arg accessor method folds by
reading the method body: a match ($this) arm for that case, or a plain constant return. A folded
status() becomes the response’s status, preferred over the status inferred from the exception type.
The bounds are deliberate:
- A method whose body is computed rather than a direct constant —
return __($this->titleKey());,return strtoupper($this->name);— does not fold. The member is documented at its widened type with noconst, and illustrated from that schema like any other member nothing folded — never pinned to a guessed value. - A translated title is the commonest of those, and the refusal is deliberate rather than a bound
nobody got to. The translator answers in the process running the build, under whatever locale that
process happens to have, so folding
__('errors.forbidden')would publish one machine’s words as your contract and move the document’s bytes withapp.locale. Anything the served request decides is the same argument: an RFC 9457instancewritten as$request->getPathInfo()is a different value on every response, so it is described and illustrated, never pinned. - A vendor enum’s methods are never analyzed (the project-only boundary applies to enums too); its
->value/->namestill fold. - If the arm passes a
ProblemTypeit can’t pin to one case (a variable,ProblemType::from($code)), nothing folds and the response falls back to the exception’s inferred status.
This descent is bounded and project-only: it follows a helper into another helper up to a small
depth and file budget, and it never steps into framework or vendor code — if the shape is produced by
a vendor call, the response is documented without a recovered body. An arm that returns null (or has
no return) is treated as “delegate to the framework”, not an error — the next source documents those
exception types. When a renderer genuinely can’t be read, Docuccino emits one summary diagnostic per
renderer (naming the exception types it skipped), not one per exception.
When the error body is a Data class
Section titled “When the error body is a Data class”A renderer that hands the body to spatie/laravel-data —
ProblemDocumentData::notFound($e)->toProblemResponse($request) — is read through: toResponse() and
transform() are modeled, so the response is documented as that Data class rather than losing its
schema to a bare JsonResponse. See
Data classes as error bodies.
One class is one component, so a single shared error Data class documents one shape for every status
that uses it — there’s no per-status variant of it, and #[Hidden]
hides a property everywhere or nowhere.
A member only some statuses carry
Section titled “A member only some statuses carry”422 carries validation errors the other statuses don’t, and one runtime class is one component — so
the shared body can’t grow errors under 422 and drop it under 404. Publish the member as optional
and say when it arrives:
"ProblemDocument": { "type": "object", "properties": { "type": { "type": "string", "format": "uri" }, "title": { "type": "string" }, "status": { "type": "integer" }, "errors": { "description": "Present on validation failures.", "type": "object", "additionalProperties": { "type": "array", "items": { "type": "string" } } } }, "required": ["type", "title", "status"]}That’s the true statement about the class: the same class serializes every status your renderer
produces, so a client reading errors as maybe-absent is reading your API correctly.
If the statuses genuinely carry different shapes and you want the stronger contract — errors required
under 422, absent from the rest — the shape has to be split per status, and each split shape is
another published name a generated client turns into another type. Two ways, both public API:
| Route | Reach for it when |
|---|---|
A Data class per status, pointed at with #[Response(status: 422, type: …)] |
The statuses really are separate types in your application. |
A DocumentTransformer carrying #[ExtensionOrder(before: [SharedErrorResponses::class])] |
The class stays one class and you want the document to say more than the class does. Running ahead of the shared-error hoist means your per-status shapes are the ones that get hoisted. |
Both contracts are covered in writing extensions, and the ordering attribute in controlling order.
Framework defaults
Section titled “Framework defaults”With error_responses set to default (the shipped value), Docuccino documents Laravel’s stock JSON
error shapes for the framework exceptions it recognizes, using the standard HTTP reason phrases as
descriptions. Matching is subtype-aware, so your own subclass of any of these inherits its shape:
| Exception | Status | Body |
|---|---|---|
Illuminate\Validation\ValidationException |
422 |
{ message, errors } — errors is a field-keyed map of message lists |
Illuminate\Auth\AuthenticationException |
401 |
{ message } |
Illuminate\Auth\Access\AuthorizationException |
403 |
{ message } |
Illuminate\Database\Eloquent\ModelNotFoundException |
404 |
{ message } |
Illuminate\Database\RecordsNotFoundException |
404 |
{ message } — so a bare sole() / firstOrFail() on the query builder is covered |
Symfony\Component\HttpKernel\Exception\BadRequestHttpException |
400 |
{ message } |
Symfony\Component\HttpKernel\Exception\UnauthorizedHttpException |
401 |
{ message } |
Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException |
403 |
{ message } |
Symfony\Component\HttpKernel\Exception\NotFoundHttpException |
404 |
{ message } |
Symfony\Component\HttpKernel\Exception\MethodNotAllowedHttpException |
405 |
{ message } |
Symfony\Component\HttpKernel\Exception\NotAcceptableHttpException |
406 |
{ message } |
Symfony\Component\HttpKernel\Exception\ConflictHttpException |
409 |
{ message } |
Symfony\Component\HttpKernel\Exception\GoneHttpException |
410 |
{ message } |
Symfony\Component\HttpKernel\Exception\LengthRequiredHttpException |
411 |
{ message } |
Symfony\Component\HttpKernel\Exception\PreconditionFailedHttpException |
412 |
{ message } |
Symfony\Component\HttpKernel\Exception\UnsupportedMediaTypeHttpException |
415 |
{ message } |
Symfony\Component\HttpKernel\Exception\UnprocessableEntityHttpException |
422 |
{ message } — no errors map: that shape comes from a validator, and this exception carries none |
Symfony\Component\HttpKernel\Exception\LockedHttpException |
423 |
{ message } |
Symfony\Component\HttpKernel\Exception\PreconditionRequiredHttpException |
428 |
{ message } |
Symfony\Component\HttpKernel\Exception\TooManyRequestsHttpException |
429 |
{ message } — so Laravel’s ThrottleRequestsException is covered by inheritance |
Symfony\Component\HttpKernel\Exception\ServiceUnavailableHttpException |
503 |
{ message } |
Illuminate\Http\Exceptions\MalformedUrlException |
400 |
{ message } |
Illuminate\Routing\Exceptions\InvalidSignatureException |
403 |
{ message } |
Illuminate\Http\Exceptions\PostTooLargeException |
413 |
{ message } |
The HttpException subclasses are there because each one fixes its status in its own constructor, where
no analyser can read it — Docuccino knows the number the way you do, from the class you threw. The bare
HttpException is not in the table: its status is an argument, so there is nothing about the class to
look up.
Anything outside that table still gets documented if its status is known: the final fallback tier emits
{ "message": string } under the exception’s status, or 500 when no status could be determined. Set
error_responses to none to emit no error responses at all.
When a 500 is a stand-in
Section titled “When a 500 is a stand-in”A 500 in your document is not one fact. It is the right answer when your code says so, and it is the
right answer for an exception that is no HTTP error at all — Laravel really does answer 500 for a bare
RuntimeException. It is a stand-in only in the third case: the error is an HttpException
subclass, nothing in your code stated a number for it, and the table above has no row for the class
either. A response has to be keyed by status, so it is filed at 500 — a key the document cannot do
without, not a claim about what your server sends.
Those three look identical from the outside, so the document says which it is. A stand-in carries one member and the other two carry nothing:
"500": { "description": "Internal Server Error", "x-docuccino": { "facts": { "statusUnplaced": true } }}docuccino:explain prints the same thing in words, beside the class and the line the throw is written
at:
responses.500 ! this status is a stand-in: nothing read one for the error filed here from App\Exceptions\LedgerRejectedException app/Queries/LedgerReviewQuery.php:28Two things follow. Do not suppress a 500 because you assume it is a placeholder — check the member
first, or you will delete a status your API really answers with. And if you want the build to fail while
any endpoint publishes a status nothing read, that member is what to gate on: it appears on exactly the
responses that stand in, in the exported document and in docuccino:explain --json alike.
Where the build stops looking
Section titled “Where the build stops looking”Two shapes end the trace, and an endpoint whose only error is one of them publishes no error response and no diagnostic — there is nothing for either to be about:
- a
throwwritten inside acatchblock, which the analysis does not surface; - a
throwin a private helper of a collaborator your action calls, which is one hop past where the analysis descends.
Both are reachable by moving the throw to somewhere the call can see — a public method on the
collaborator, or a guard clause before the try — or by documenting the response with
#[Response(status: …)] on the action.
Both of those bodies are the framework’s, so neither is published over a handler of yours that replaces it. Where Docuccino can see that your handler renders an exception and cannot read what it renders it to, the response keeps its status and its description and carries no body at all — a shape your server does not send would leave every generated client with the wrong type for that error. The diagnostic names the callback to fix.
// app/Exceptions/ProblemRenderer.php — a plain response, and a status read off the exceptionpublic function __invoke(ModelNotFoundException $e): Response{ return response($this->body($e), $e->getCode() ?: 404);}"404": { "description": "Not Found"}Nothing is minted into components either: there is no body for a NotFound entry to hold, so every
operation that can throw it publishes those same two lines rather than a $ref. The tier still
answers — standing aside would only hand the same guess to the tier behind it — so no later tier
fills the gap, and the same restraint covers the 429 a throttled
route documents.
One reading survives a body that would not fold: a handler that named a content type. That comes from your code rather than from a classification of the exception, so the response states that media type, with a schema constraining nothing — “a problem+json body, shape unread” rather than a shape nobody saw.
Implicit responses (middleware, bindings & validation)
Section titled “Implicit responses (middleware, bindings & validation)”Some error responses never appear as a throw in your action — the framework produces them from
middleware or while resolving the request. Docuccino synthesizes these from statically-visible
signals and runs each through the same resolution above, so the body matches the rest of the document’s
errors:
| Status | When it’s added | How to opt out |
|---|---|---|
401 |
The route has authentication middleware (matching security.auth_middleware). |
Mark the route #[Unauthenticated] — or #[IgnoreResponse(401)]. |
422 |
A validated request body was recovered (Spatie Data, a form request, or an action’s rules()). |
#[IgnoreResponse(422)]. |
404 |
The route binds a model to a path parameter (implicit binding). One 404 per operation. |
#[IgnoreResponse(404)]. |
403 |
The route has can: / signed / verified middleware, or a form request authorize() gate that isn’t a literal return true. |
#[IgnoreResponse(403)]. |
If your action also throws one of these explicitly, the two merge into a single response for that
status — you never get a duplicate. 429 is documented by the rate-limiting
integration, and takes its body from this same resolution, so an app whose handler renders
application/problem+json gets a problem document there too — and where your handler demonstrably
renders throttling but Docuccino cannot read what it renders it to, the 429 states its status, its
reason and its rate-limit headers and says nothing about the body, exactly as the statuses above do. Set
error_responses to none to turn all of this off.
Repeated bodies become shared components
Section titled “Repeated bodies become shared components”Error contracts repeat by nature: the same 404 shape sits under every show route, the same 422 under
every write. Docuccino collapses that repetition in two independent passes.
The shape goes into components.schemas. A body shape two or more operations state identically is
stated once, and every response that returns it points at that one entry.
The whole response goes into components.responses. Where operations state the same response —
description and headers included — that response is hoisted too, and each operation becomes a one-line
$ref.
// Every operation returning this 404, however each one illustrates it"404": { "$ref": "#/components/responses/NotFound" }// components.responses — one contract, every operation's illustration of it"NotFound": { "description": "Not Found", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/NotFound" }, "examples": { "example_2obip4vj": { "value": { "message": "No such invoice." } }, "example_hq4wxk7d": { "value": { "message": "No such customer." } } } } }}
// components.schemas"NotFound": { "type": "object", "properties": { "message": { "type": "string" } }, "required": ["message"]}| Hoisted | Not hoisted |
|---|---|
The body shape of a 4xx/5xx two or more operations state identically |
A shape no other operation shares |
| A response two or more operations state identically, however each illustrates or describes it | A response no other operation matches, a description-only response, or one that is already a $ref — a status an #[ErrorComponent] already named, say |
How an operation illustrates an error is never part of its contract. Two 403s returning the same
body with different example messaging are one Forbidden. Where the operations agree on one
illustration it stays the media type’s single example; where they genuinely differ, they become an
examples map keyed from each body’s own content, so adding or removing an endpoint never renames
another one’s example.
An illustration is only kept where it says something the others don’t. A member no render path
spelled out is filled from its schema so the example stays a
valid instance of the shape beside it, and an arm differing from another only at members it filled
that way is dropped — "string" is not a value your server sends, and offering it as a second body
would show a reader two shapes where your code has one. An example you wrote yourself, or one recorded
from your test suite, is never dropped this way: nothing in it was filled, so every member it states is
one it can stand behind.
Neither is how it describes one. Two operations answering 404 with the same body share one
component even where each words it differently: the shared response publishes the wording the most
operations state, and an operation that says something else keeps its own words beside its $ref, where
OpenAPI 3.1 and 3.2 define a reference’s summary and description as overriding the ones it points at.
// The wording most operations state — nothing to override"404": { "$ref": "#/components/responses/NotFound" }
// One taxonomy endpoint, in its own words"404": { "$ref": "#/components/responses/NotFound", "description": "No taxonomy term matches the given slug"}Before: two components, Error404_nuowcygb and Error404_dl33vd2k — same body, same headers, different words — so one endpoint's phrasing renamed the 404 type the other 145 operations published under.After: one NotFound, and every operation keeps the words it was given.An OpenAPI 3.0 export drops that override — 3.0 ignores anything beside a $ref — and says so with a
downlevel.ref-siblings note, so a 3.0 reader sees the shared component’s own wording.
Names you chose yourself survive that merge. Where an operation’s error carries
#[Example(name: …)] — or a
recorded name, which is a name you chose at a
call site — the shared response publishes your key next to the generated ones, so annotating one route
never changes what the others publish. The exception is two operations giving one name to two different examples: only you can say
which body that name means, so neither gets it — each example publishes under that name plus a hash of
its own content, and the build reports components.example-name-conflict naming the key. Rename one, or
make them agree, and the name is published as you wrote it. The component they share is never what pays
for the disagreement.
Every 429 is identical — rate-limit headers are documented by
meaning, never by value — so throttled routes share both.
Shared errors are named after the error
Section titled “Shared errors are named after the error”A component name is what a code generator calls the type — what someone consuming your API ends up
writing in a catch, having never seen the code behind it. Laravel’s own errors are published under
their reason phrase, because the tier documenting them speaks for exactly one kind of error per status
and can say which:
| Status | Component | Produced for |
|---|---|---|
400 |
BadRequest |
BadRequestHttpException, MalformedUrlException, the Query Builder strict-mode rejection, and an unrecognized exception with a 400 status |
401 |
Unauthorized |
AuthenticationException, UnauthorizedHttpException, and auth middleware |
403 |
Forbidden |
AuthorizationException, AccessDeniedHttpException, InvalidSignatureException, can: / signed / verified middleware, a form request authorize() gate |
404 |
NotFound |
ModelNotFoundException, RecordsNotFoundException, NotFoundHttpException, and route–model binding |
413 |
ContentTooLarge |
PostTooLargeException |
422 |
UnprocessableEntity |
ValidationException, UnprocessableEntityHttpException, and any recovered request body |
429 |
TooManyRequests |
ThrottleRequestsException, TooManyRequestsHttpException, and throttle middleware |
500 |
InternalServerError |
An exception carrying no status anything could read |
405, 406, 409, 410, 411, 412, 415, 423, 428, 503 |
MethodNotAllowed, NotAcceptable, Conflict, Gone, LengthRequired, PreconditionFailed, UnsupportedMediaType, Locked, PreconditionRequired, ServiceUnavailable |
Symfony’s HttpException subclass for that status — MethodNotAllowedHttpException, ConflictHttpException and the rest — or an unrecognized exception resolving to it |
Every one of Symfony’s HttpException subclasses is in that table, because each one fixes its status in
its own constructor. Nothing has to read the number out of it: throw GoneHttpException and the
operation documents a 410. A subclass of your own inherits the status the same way — Laravel’s
ThrottleRequestsException is documented as a 429 because it extends TooManyRequestsHttpException —
so the one that falls through to 500 is the bare HttpException, whose status is an argument rather
than a fact about the class.
An error nothing claims a name for still falls back to Error<status>, the statuses in that table
included — the reason phrases belong to the tier documenting those errors, never to the status. A 402
your handler renders, a 404 it renders in the framework’s place, or a body an
attribute documents: each publishes as Error<status>.
Nothing else about those bodies changes — only what the shared component is called.
Named and unnamed bodies still share on the same terms. Whether a body is hoisted at all depends on how
often the document states that status and those bytes, and never on what anyone called it — so a 404
your own handler renders as {"message": …} is hoisted exactly as it was, under Error404, and the
framework’s identical 404 is hoisted beside it under NotFound.
A name goes to more than one component only when two bodies genuinely contest it. When that happens the
plain NotFound is retired rather than handed to one of them, and each takes a name derived from its own
content (NotFound_kzvq2m4a) — so adding a route can never change which body an existing $ref means.
Retiring the plain name is a rename, and it is announced. A document publishing NotFound today
loses it the moment one unrelated endpoint introduces a second 404 shape: both bodies move to
content-derived names and every operation that referenced NotFound is repointed. Nothing about either
body changed, but a published name did move, so the build reports a components.name-collision warning
naming the retired name and both replacements. The same warning fires when a component from elsewhere in
your app already holds the name (a class of your own called NotFound), in which case the shared body
climbs past it.
Name your own errors with #[ErrorComponent]
Section titled “Name your own errors with #[ErrorComponent]”Your exceptions get the same treatment, and you say what they are called by marking the class:
use Docuccino\Attributes\ErrorComponent;
#[ErrorComponent('ResourceMissing')]final class InvoiceNotFoundException extends RuntimeException {}// Every operation that throws it"404": { "$ref": "#/components/responses/ResourceMissing" }
// components.responses"ResourceMissing": { "description": "Not Found", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ResourceMissing" } } }}Same status, same body, same description — only the name a generated client gives the type changes.
It names the response, and the shape under it where that shape is the whole response. A 404
answered with one body publishes both as ResourceMissing, which is what lets a client catch the name
you chose. A response that offers two representations — a problem body and a plain-JSON alternative, say —
is a different thing: calling either shape ResourceMissing would say it is the error when it may be the
alternative beside it, and so would calling the response that offers both. Such a response is named after
the components its representations reference, and each shape publishes under its status. See
Name one operation’s error for the way to override
that.
It names errors where they are defined, so it does not go on the action. PHP will let you write
#[ErrorComponent] on a controller method and nothing reads it there — an action is where several of
your errors meet, and the attribute carries no status, so there is nothing for it to name. The build
reports attribute.error-component-unread rather than leaving you to wonder — for the action’s own
declaration. One on a base controller is silent, because it would say the same thing on every route
of every child, and nothing it changes was ever going to be different. For one status of one operation,
name it with #[Response(errorComponent:)] instead.
You get that warning whether or not the document publishes error responses at all: a misplaced attribute
is misplaced under error_responses => 'none' too,
which is what an application that never published a config file resolves to.
It renames a shared component; it does not create one. Which bodies are
shared is settled before anything is named, and marking a
class doesn’t change it: an error only one operation states stays inline, so a one-off error publishes no
component and no $ref, attribute or no attribute. The name travels with that response, and the day a
second operation states the same error the one you marked is published as ResourceMissing instead of
under its status.
It is inherited, unlike PHP’s own attribute lookup. A base your API errors already extend names them all at once, and a subclass that carries its own attribute wins over the base:
#[ErrorComponent('ApiFailure')]abstract class ApiException extends RuntimeException {}
// Published as ApiFailure…final class InvoiceNotFoundException extends ApiException {}
// …and this one as PolicyRefused.#[ErrorComponent('PolicyRefused')]final class PolicyDeniedException extends ApiException {}It names a status that has no name of its own. A 410 is Error410 by default, because nothing in
the table above speaks for it. The attribute is the only way that body gets called something a client
can read.
Two exceptions may share one name. Different classes raising the same error is normal, and where their bodies match under one status they publish one component. Where the bodies differ the name is contested and retired exactly as above, each body taking a name derived from its own content.
Say what the error is with #[Description]
Section titled “Say what the error is with #[Description]”A name tells a client what to catch. #[Description] beside it tells them what it means, and it is
published as the description of the schema they are handed:
use Docuccino\Attributes\Description;use Docuccino\Attributes\ErrorComponent;
#[ErrorComponent('ResourceMissing')]#[Description(text: 'No record matches the identifier in the path.')]final class InvoiceNotFoundException extends RuntimeException {}// components.schemas"ResourceMissing": { "description": "No record matches the identifier in the path.", "type": "object", "properties": { "message": { "type": "string" } }}Whoever reads it has never seen your codebase, so write what went wrong and what it means for their request — not which attribute to add or which class threw.
It goes on the class that carries the #[ErrorComponent]. The sentence describes the error the name
names, so the two are read together: a base that names ApiFailure and describes it speaks for every
subclass inheriting both, and a subclass that renames its error to PolicyRefused describes it itself or
not at all. The base’s sentence is about ApiFailure, and publishing it on PolicyRefused would say
something untrue about an error a client catches.
Two errors sharing a name have to agree. Where classes publishing one component describe it two
different ways, neither sentence is published — a schema states one description, and picking one would put
one author’s words on the other’s error. The build reports components.description-conflict and quotes
both. The name is untouched either way: what a component is called has never depended on the prose beside
it.
Only one of text: and file:, and never request:. A schema description is read from the attribute
itself — nothing resolves an application path here, and a request body belongs to an operation rather than
to a type — so those forms are reported and ignored, as they are anywhere else a type is described. A
#[Description] on a render method is not read; name the body there and describe it on the class.
Name the body instead, when the class can’t say enough
Section titled “Name the body instead, when the class can’t say enough”On a class the attribute says one thing per class, and a renderer that turns one family of exceptions into several different bodies needs to say more than that. Put it on the render method and it names the body that method answers with:
use Docuccino\Attributes\ErrorComponent;
final class ProblemRenderer{ public function __invoke(Throwable $e, Request $request): ?JsonResponse { if ($e instanceof ApiException && $e instanceof HasInvalidFields) { return $this->renderRejection($e); }
if ($e instanceof ApiException && $e instanceof HasRetryWindow) { return $this->renderThrottle($e); }
return $e instanceof ApiException ? $this->renderProblem($e) : null; }
#[ErrorComponent('InvoiceRejected')] private function renderRejection(ApiException&HasInvalidFields $e): JsonResponse { return $this->problem(['title' => 'Rejected', 'fields' => $e->fields()], 422); }
#[ErrorComponent('InvoiceThrottled')] private function renderThrottle(ApiException&HasRetryWindow $e): JsonResponse { return $this->problem(['title' => 'Slow down', 'retryAfter' => $e->retryAfter()], 429); }
// Declares nothing, so the house name below stands for it. private function renderProblem(ApiException $e): JsonResponse { return $this->problem(['title' => $e->getMessage()], $e->getStatusCode()); }
#[ErrorComponent('InvoiceProblem')] private function problem(array $body, int $status): JsonResponse { return new JsonResponse($body, $status, ['Content-Type' => 'application/problem+json']); }}// Three arms, three names — instead of one name three bodies would have contested."422": { "$ref": "#/components/responses/InvoiceRejected" }"429": { "$ref": "#/components/responses/InvoiceThrottled" }"503": { "$ref": "#/components/responses/InvoiceProblem" }Use it when one exception class produces more than one body, or when the class is a framework or vendor one you can’t mark. Where every error of a class really is one error — the usual case — the class is the better place: it is one line, and it is inherited.
The method nearest the answer wins, not the one nearest the body. Docuccino follows the render path
from the method it analyzes down through the helpers it calls, and takes the first name it finds. So the
arms above beat problem(), which builds all three of them, and problem() names only the arms that
said nothing themselves. It works the other way round for a renderable exception — a render() on the
exception itself is both ends of its own path, and marking it names the one body it returns.
Precedence, in order: #[Response(errorComponent:)] on the operation, then the name a mapper gave the body
it built, then #[ErrorComponent] on the render method, then #[ErrorComponent] on the exception class,
then the status default in the table above. The chain resolves to the first mapper that answers, so a
mapper that must beat the renderer Docuccino read orders itself ahead of it.
Name one operation’s error, wherever the body came from
Section titled “Name one operation’s error, wherever the body came from”#[ErrorComponent] names an error where the error is defined — an exception class, or the method that
renders one — so every operation answering with it publishes that name. #[Response(errorComponent:)]
names one status of one operation, and it does not care what produced the body. That covers the case
neither #[ErrorComponent] anchor can reach at all — a 4xx your operation declares, where nothing was
thrown and no renderer built it — and equally a body an exception the action throws produced, where it
simply wins as the declaration nearest the operation:
use Docuccino\Attributes\Response;
#[Response(status: 422, type: SignInChallenge::class, mediaType: 'application/json', errorComponent: 'AuthenticationChallenge')]public function completeMfa(Request $request): SuccessData { /* … */ }// Every operation that states the same 422"422": { "$ref": "#/components/responses/AuthenticationChallenge" }
// components.responses"AuthenticationChallenge": { "description": "Unprocessable Entity", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/SignInChallenge" } } }}This is the escape hatch for a name the defaults get wrong, and the one that reaches a response offering
two representations — a name written on the operation is about the whole response, unlike one a renderer
gave the body it built. It outranks #[ErrorComponent] on the exception the action throws, because the
declaration nearest the operation wins.
It renames a shared component and does not create one, exactly as #[ErrorComponent] does: a body only
one operation states stays inline and has nothing to name. And because a response component covers every
representation of a status, the name is the status’s — where two declarations at one status name
different components the nearer one wins, a method’s over its controller’s, the same way every other
argument of the attribute settles.
The shapes under it are a separate question, and the answer there is deliberate rather than a gap. A response component covers every representation of a status; a schema component is one shape. So where the status states a single representation the shape is the whole of the named error and takes the name with it. Where it states two, naming either shape after the response would assert that shape is the error you named, when it may be the alternative sitting beside it. Neither takes it, and your name stays on the response.
Name the shapes themselves instead. A shape type: points at is already named after that class — as
SignInChallenge is above — and #[SchemaName] renames it
where the class name isn’t what you want a client to see. What falls back to Error<status> is a shape
nothing named at all, so finding Error422 under a nicely-named response means one representation has no
declaration of its own, not that the attribute was ignored.
Where the name can reach nothing at all, the build says so with
attribute.error-component-unreachable: on a status below 400, which shares no error body, and on one
a mapper answered with a $ref to a component that was named where that component is defined.
When neither attribute is enough
Section titled “When neither attribute is enough”| A body no renderer of yours builds | Nothing on the path to mark. Register an ExceptionToResponse and name each body as you build it. The name travels with the body and beats both #[ErrorComponent] anchors — a #[Response(errorComponent:)] on the operation is the one thing above it, so an action that declares the status names it there instead. |
| An exception you didn’t write, with no renderer either | You can’t mark a framework or vendor class. Rename the component a built-in claimed from an OperationExtension instead. |
Both are in naming the component an error publishes
under. An overlay is
not one of them, because overlays run before the hoist and there is nothing at
components.responses.* to target yet.
A name outside ^[a-zA-Z0-9._-]+$ is refused with an attribute.error-component-invalid warning naming
the declaration that carried it — the exception class, the render method, or the #[Response] — and the
response keeps the name it would have had: the status default from the table above, or Error<status>
where the status has none. A refused name never contests a good one, so a legal name at the same status
still wins.
Two render methods naming the same component for bodies that differ is settled like every other
contest: both bodies keep a name derived from their own content and the build reports
components.name-collision.
Where a response or a shape lives is representation, not contract, so
docuccino:diff reads a $ref as the thing it names on
both sides. The hoist itself is not a change: a response moving between inline and
components.responses, and a body shape moving between inline and components.schemas, each report
nothing at the operations they left, and the changeset carries only the component arriving. What is
still reported is a hoist that also edits the shape — the component is compared against what the
inline copy said — and an edit to the shared body afterwards, which is reported once, at the
component, rather than under every operation pointing at it. Diffing your
API has what to expect. In the full document each operation also keeps its
own response id and provenance in x-docuccino beside the $ref.
Set representation.errors.components to false to
keep every copy inline.
Configuration
Section titled “Configuration”One key, in docuccino.yaml:
documents: default: error_responses: 'default'| Key | Values | Default | Effect |
|---|---|---|---|
error_responses |
default | none |
default |
What to publish for the exceptions your application does not render itself. Your inferred handlers take precedence regardless; none also turns off the implicit responses above, the throttled 429 among them. |