Skip to content

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:

  1. Your exception handling — the shape your code really returns.

  2. The Problem Details preset — RFC 9457 responses, when you opt in.

  3. Framework defaults — Laravel’s stock JSON shapes for the exceptions it renders itself.

  4. A generic fallback — any other exception with a known status gets { "message": string } under that status (500 when the status can’t be determined), so an error is never simply undocumented.

Docuccino reads your app’s real error handling and documents the exact shape it produces. For each exception it looks, in order, for:

  1. A render callback registered in bootstrap/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 scanning bootstrap/app.php as text.

  2. The exception’s own render() method.

  3. A Responsable exception’s toResponse().

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:

app/Exceptions/PaymentRequiredException.php
public function render(Request $request): JsonResponse
{
return response()->json([
'message' => 'Payment is required to continue.',
'invoice_id' => $this->invoiceId,
], 402);
}

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.

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()),

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.

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),

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 no const or example, never a guessed value.
  • A vendor enum’s methods are never analyzed (the project-only boundary applies to enums too); its ->value/->name still fold.
  • If the arm passes a ProblemType it 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.

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

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.

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

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.

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.