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 four tiers. The first tier that can produce a shape wins, and your attributes, docblocks, overlays or config always override the result:
-
Your exception handling — the shape your code really returns.
-
The Problem Details preset — RFC 9457 responses, when you opt in.
-
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.
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 — no annotation
required. 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 moves on to the next tier.
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 when the helper sets an explicit Content-Type (so an application/problem+json factory
is documented under application/problem+json, not application/json). 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.
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" } } }, "example": { "type": "https://errors.example/forbidden", "title": "Forbidden", "status": 403 } } }}Notice what the 403 shows and what it doesn’t: the per-arm type URL, the title, and the 403
are all constants read from the arm, so they appear as both consts and in the example. A status
member that simply carries the response’s status code is filled with this response’s status — the
403 arm says 403, a 404 arm says 404 — even when the status itself was computed dynamically.
The detail here is $e->getMessage(), which has no static value, so it is omitted from the
example rather than invented. Docuccino never fabricates an example value: only what actually flows
into the body is shown, and the example is deterministic — identical code always produces identical bytes.
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 — the enum is the source of truth, so it’s preferred over the
status inferred from the exception type.
The honest 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 noconstor example, never a guessed value. - 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 rather than reaching into internals.
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.
The Problem Details preset (opt-in)
Section titled “The Problem Details preset (opt-in)”Prefer RFC 9457 application/problem+json responses? Turn
on the preset:
// config/docuccino.php → documents.default'error_responses' => 'problem-details',The framework exceptions become reusable #/components/responses/Problem* entries, all built on one
shared ProblemDetails schema (type, title, status, detail, instance) — so an operation’s
error responses are a $ref, not a copy, and the shape is described exactly once:
"responses": { "201": { "description": "Created", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/InvoiceResource" } } } }, "422": { "$ref": "#/components/responses/ProblemValidation" }}"ProblemDetails": { "type": "object", "properties": { "type": { "type": "string", "format": "uri", "default": "about:blank" }, "title": { "type": "string" }, "status": { "type": "integer" }, "detail": { "type": "string" }, "instance": { "type": "string", "format": "uri" } }},// components.responses"ProblemValidation": { "description": "Validation failed", "content": { "application/problem+json": { "schema": { "allOf": [ { "$ref": "#/components/schemas/ProblemDetails" }, { "type": "object", "properties": { "errors": { "type": "object", "additionalProperties": { "type": "array", "items": { "type": "string" } } } } } ] }, "example": { "type": "about:blank", "title": "Unprocessable Entity", "status": 422, "detail": "Validation failed.", "errors": { "field": ["The field is invalid."] } } } }}The shared schema stays open — no additionalProperties: false — so your app is free to add its own
members. Every response carries a worked example, and the components you get are:
| Component | Status | For |
|---|---|---|
ProblemValidation |
422 |
ValidationException — adds the errors member |
ProblemUnauthenticated |
401 |
AuthenticationException |
ProblemForbidden |
403 |
AuthorizationException |
ProblemNotFound |
404 |
ModelNotFoundException, NotFoundHttpException |
Problem<status> |
that status | Any other HttpException whose status folds to a constant |
Your own inferred handlers still win where they apply, so the preset only covers what you haven’t handled yourself.
Choosing the 422 errors shape
Section titled “Choosing the 422 errors shape”By default errors is a field-keyed map of message lists, matching Laravel’s stock validation JSON.
RFC 9457 implementations often prefer a list of JSON-Pointer objects instead — pass the bag form to
switch:
'error_responses' => ['preset' => 'problem-details', 'errors_shape' => 'pointer-list'],"errors": { "type": "array", "items": { "type": "object", "properties": { "detail": { "type": "string" }, "pointer": { "type": "string" } }, "required": ["detail", "pointer"] }}// example: [ { "detail": "The field is invalid.", "pointer": "#/field" } ]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\…\HttpKernel\Exception\NotFoundHttpException |
404 |
{ message } |
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.
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 your chosen style
(framework defaults or the Problem Details preset):
| Status | When it’s added | How to opt out |
|---|---|---|
401 |
The route has authentication middleware (matching security.auto_detect_middleware). |
Mark the route #[Unauthenticated] — or #[IgnoreResponse(401)]. |
422 |
A validated request body was recovered (Spatie Data, a FormRequest, 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 FormRequest 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. Set error_responses to none to turn all of this off.
Configuration
Section titled “Configuration”One key, in either a string or a bag form:
// config/docuccino.php → documents.default'error_responses' => 'default',
// Or, to also choose how a Problem Details 422 models `errors`:'error_responses' => ['preset' => 'problem-details', 'errors_shape' => 'pointer-list'],| Key | Values | Default | Effect |
|---|---|---|---|
error_responses (or preset) |
default | problem-details | none |
default |
Which fallback strategy to use. Your inferred handlers take precedence regardless; none also turns off the implicit responses above. |
errors_shape |
map | pointer-list |
map |
The Problem Details 422 errors shape. Only meaningful with the problem-details preset. |