Skip to content

Validation error shape

The generated scaffold enforces the request half of the contract at runtime: a spec-violating body or query payload raises a standard Illuminate\Validation\ValidationException before your code runs. Laravel then renders that exception with its default 422 JSON body:

{
"message": "The sku field is required. (and 1 more error)",
"errors": {
"sku": ["The sku field is required."],
"quantity": ["The quantity field must be at least 1."]
}
}

That shape is almost never what your spec declares for the 422 response. If the spec says the validation failure looks like { type, title, status, violations: [...] }, the scaffold’s failure path contradicts the contract out of the box: the request is correctly rejected, but the rejection body is Laravel’s, not the spec’s. This page shows how to align the two with one exception renderer, using the error Data classes the generator already emits.

Decision: a documented renderer, not a generated one

Section titled “Decision: a documented renderer, not a generated one”

This was an explicit design decision (issue #79): the generator ships a recipe, not a generated renderer. A survey of the 135-spec corpus shows there is no common error shape to generate against: FastAPI’s HTTPValidationError (a detail array of loc/msg/type), GitHub’s validation_failed, RFC 9457 problem-details variants, flat { code, message } objects, and one-off inline shapes all coexist, and many specs declare the validation failure on 400 rather than 422. The mapping from Laravel’s error bag into such a shape (which field carries what, how per-field messages flatten, what a type or code value means) is application semantics the spec does not encode, so a generated renderer would either guess wrong for most specs or need so much configuration that writing it would equal writing the renderer by hand. Exception rendering is also one app-global decision that belongs in your bootstrap/app.php, not in the regenerable file set. What the generator does contribute is the typed half: the spec’s error schemas already generate Data classes, so the recipe below is type-checked end to end.

Every named schema under components.schemas becomes a Data class, including schemas that are only referenced from error responses. No flag is needed. Given:

paths:
/orders:
post:
operationId: createOrder
tags: [Orders]
requestBody:
required: true
content:
application/json:
schema: { $ref: '#/components/schemas/OrderCreate' }
responses:
'201':
description: Created
content:
application/json:
schema: { $ref: '#/components/schemas/Order' }
'422':
description: Validation failed
content:
application/json:
schema: { $ref: '#/components/schemas/ValidationError' }
components:
schemas:
ValidationError:
type: object
required: [type, title, status, violations]
properties:
type: { type: string }
title: { type: string }
status: { type: integer }
violations:
type: array
items: { $ref: '#/components/schemas/Violation' }
Violation:
type: object
required: [field, message]
properties:
field: { type: string }
message: { type: string }

the generator emits ValidationErrorData and ViolationData alongside the request and response classes:

final class ValidationErrorData extends Data
{
public function __construct(
public readonly string $type,
public readonly string $title,
public readonly int $status,
/** @var array<int, ViolationData> */
#[DataCollectionOf(ViolationData::class)]
public readonly array $violations,
) {}
}

Building the 422 body through this class instead of a hand-rolled array means the error shape is pinned by PHP’s type system: rename a spec field and regenerate, and the renderer stops compiling instead of silently drifting.

Laravel 11+ configures exception handling in bootstrap/app.php via withExceptions. Register a rendering closure type-hinted on ValidationException; Laravel matches the closure by that type hint, and a returned response replaces the default rendering (returning nothing falls through to the default):

bootstrap/app.php
use App\Data\ValidationErrorData;
use App\Data\ViolationData;
use Illuminate\Foundation\Application;
use Illuminate\Foundation\Configuration\Exceptions;
use Illuminate\Http\Request;
use Illuminate\Validation\ValidationException;
return Application::configure(basePath: dirname(__DIR__))
// ->withRouting(...)
->withExceptions(function (Exceptions $exceptions): void {
$exceptions->render(function (ValidationException $e, Request $request) {
if (! $request->expectsJson()) {
return; // keep Laravel's redirect-back behavior for web forms
}
$violations = [];
foreach ($e->errors() as $field => $messages) {
foreach ($messages as $message) {
$violations[] = new ViolationData(field: $field, message: $message);
}
}
return response()->json(new ValidationErrorData(
type: 'https://example.com/problems/validation-error',
title: 'Validation failed.',
status: $e->status,
violations: $violations,
), $e->status);
});
})->create();

For the spec above, a POST /orders with a missing sku and a zero quantity now answers:

{
"type": "https://example.com/problems/validation-error",
"title": "Validation failed.",
"status": 422,
"violations": [
{ "field": "sku", "message": "The sku field is required." },
{ "field": "quantity", "message": "The quantity field must be at least 1." }
]
}

Why the pieces are what they are:

  • One renderer covers every generated validation path. The injected body Data classes, the query classes’ fromQuery(), and any validate() call you make yourself all throw the same Illuminate\Validation\ValidationException, so this single closure reshapes all of them.
  • $e->errors() is the flattened bag. Field names use dot notation for nested and array fields (items.0.sku), each mapping to a list of messages. The double loop above flattens that into one violation per message; adapt the mapping to whatever your spec’s shape wants (join the messages, keep only the first, group per field, and so on).
  • $e->status, not a literal 422. ValidationException carries its status (422 by default), so a spec that declares the validation failure on 400 only needs throw $e->status(400)-style customization in one place, and the renderer follows. Several corpus specs do exactly this.
  • The expectsJson() guard keeps the classic redirect-with-errors behavior for non-API routes. In an API-only app you can drop the guard, or scope with $request->is('api/*') instead.
  • The generated status middleware does not interfere. The scaffold’s RespondsWithStatus middleware only rewrites a framework-default success status (any 2xx), so the renderer’s 422 (or 400) passes through untouched.

The recipe above reshapes the framework’s own 422, the one Laravel raises when a spec-derived rules() check fails before your code runs. A different case is a spec-declared error your own code decides to answer: a 404 when a lookup misses, a 409 on a conflicting write. The generated abstract method is typed to the operation’s success DTO (error responses are never inspected for typing), so a hand-rolled helper that returns a JsonResponse will not satisfy that return type. Throwing does: a throw never reaches the return, so the declared success type stays honored no matter which error path a method takes.

For every operation whose spec declares a named-component object error response, the generator emits a per-operation factory, <Operation>Errors, with one status-named static method per declared error. Throw it directly:

public function show(int $petId): PetData
{
return $this->store->pet($petId)
?? throw GetPetByIdErrors::notFound(message: "Pet {$petId} not found.");
}

The factory is generated alongside the operation’s other Data classes:

namespace App\Data\Pets;
use App\Data\Support\ApiError;
final class GetPetByIdErrors
{
public static function notFound(string $message): ApiError
{
return new ApiError(new PetErrorData(message: $message), 404);
}
}

Three things fall out of that shape, and each removes a way to get the error wrong:

  • The status is written once, in the method name. notFound is the 404; there is no status literal at the throw site to drift from the spec. The name comes from the spec’s declared status key (400 becomes badRequest, 404 becomes notFound, 409 becomes conflict, 422 becomes unprocessable, and so on).
  • The error DTO is never hand-rolled. The factory constructs PetErrorData for you and flattens its constructor into named parameters, so the call site passes message: directly with no new PetErrorData(...) wrapper. A nested or collection field keeps its sub-DTO array typing, exactly as the constructor declares it.
  • No registration. ApiError self-renders through Laravel’s render(Request): Response exception-handler hook, so the thrown value becomes the spec’s declared error body at the spec’s declared status with no bootstrap/app.php closure. (Contrast the 422 recipe above: that one needs a closure because Laravel’s validation machinery throws ValidationException itself, before any of your code runs.)

A single error schema shared across several statuses (a 400 and a 404 both pointing at PetError) produces one method per status, each forwarding into the same DTO, so the call site stays status-precise while the body shape stays single-sourced.

<Operation>Errors is the pattern to reach for first. When no generated method fits, the same ApiError carrier the factory forwards into is available directly, with named-status factories (badRequest, unauthorized, forbidden, notFound, conflict, unprocessable, tooManyRequests, serverError) and a general new ApiError($body, $status) constructor for any other code:

use App\Data\Support\ApiError;
throw ApiError::forbidden($errorData); // a named status
throw new ApiError($errorData, 451); // any other code

Three cases call for the escape hatch rather than a generated factory:

  • An operation whose error responses do not qualify. A v1 factory method is emitted only for a declared error whose schema resolves to a named component object. An operation whose error slot is an inline object schema (a documented residual for a fast-follow), a non-object shape, an unresolvable $ref, or a default/4XX/5XX wildcard (which names no single concrete status to throw) gets no generated method; throw ApiError with the Data class (or any Responsable/Arrayable/JsonSerializable value) you build yourself.
  • A cross-cutting error not tied to one operation. A global 401 or 403 enforced in a shared base controller or in middleware is not part of any single operation’s declared responses, so no per-operation factory covers it. ApiError::unauthorized(...) gives that throw the same typed, ergonomic home.
  • A status the spec’s per-operation responses omit. Spec authors routinely leave a 401 or 403 undeclared even when the API genuinely enforces it. The status has no generated method to reach for, so ApiError fills the gap without waiting on a spec edit.

The status names match the factory’s exactly (notFound is a 404 whether it comes from GetPetByIdErrors::notFound(...) or the carrier’s own ApiError::notFound(...)), so moving between the two layers is a mechanical change, never a semantic one.

The generator is deliberate about which gaps it surfaces. When an operation does get a factory, it warns once per declared error slot that did not become a method (an inline-object schema, a non-object shape, an unresolvable $ref, or a default/4XX/5XX wildcard), so an incomplete factory is visible at generation time. An operation with no qualifying error slot at all generates no factory and stays silent: a warning per non-object error body would flood specs whose error responses are entirely non-objects or default catch-alls (a common shape in the wild), which is noise, not information the developer needs. So it is not the case that every unsupported error response warns, only the ones on operations that already earned a factory.

The renderer is application code, so prove it in your suite. The strongest assertion is the contract one: Spectator validates the produced 422 body against the same spec file the generator reads, see contract testing:

it('answers validation failures with the declared 422 body', function (): void {
$this->postJson('/orders', ['quantity' => 0])
->assertValidResponse(422);
});

Without Spectator, assert the structure directly:

it('answers validation failures with the spec error shape', function (): void {
$this->postJson('/orders', ['quantity' => 0])
->assertStatus(422)
->assertJsonStructure([
'type', 'title', 'status',
'violations' => [['field', 'message']],
]);
});

The Spectator variant is the one worth keeping long term: when the spec’s error schema evolves, the regenerated Data class breaks the renderer at compile time and Spectator breaks the test at contract level, so the shape cannot drift in either direction.

  • Name the error shape under components.schemas. Only named component schemas generate Data classes. A 422 whose schema is declared inline in the response, or that lives only inside a components.responses entry’s content, produces no class; the renderer then builds the array by hand (Spectator still checks it against the spec). Lifting the shape into a named schema is the one-line spec change that buys the typed renderer.
  • The renderer is yours. It is written once in bootstrap/app.php and survives regeneration untouched; the generator never writes into your bootstrap file. Regenerating after a spec change updates the Data classes the renderer is built from, which is exactly the coupling you want.
  • Other error statuses: throw the generated factory. A 404 or 409 body declared in the spec also generates its Data class, and for a named-component object schema the generator emits a <Operation>Errors::notFound(...)-style factory that wraps it in a self-rendering ApiError with no bootstrap/app.php closure at all. Reach for that first (see Throwing other error statuses); the ApiError carrier covers anything a generated factory does not, and a render closure per exception type stays available when you want the framework-level hook instead.