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.
The typed half is already generated
Section titled “The typed half is already generated”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.
The renderer recipe
Section titled “The renderer recipe”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):
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 anyvalidate()call you make yourself all throw the sameIlluminate\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.ValidationExceptioncarries its status (422 by default), so a spec that declares the validation failure on 400 only needsthrow $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
RespondsWithStatusmiddleware only rewrites a framework-default success status (any 2xx), so the renderer’s 422 (or 400) passes through untouched.
Throwing other error statuses
Section titled “Throwing other error statuses”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.
notFoundis 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 (400becomesbadRequest,404becomesnotFound,409becomesconflict,422becomesunprocessable, and so on). - The error DTO is never hand-rolled. The factory constructs
PetErrorDatafor you and flattens its constructor into named parameters, so the call site passesmessage:directly with nonew PetErrorData(...)wrapper. A nested or collection field keeps its sub-DTO array typing, exactly as the constructor declares it. - No registration.
ApiErrorself-renders through Laravel’srender(Request): Responseexception-handler hook, so the thrown value becomes the spec’s declared error body at the spec’s declared status with nobootstrap/app.phpclosure. (Contrast the 422 recipe above: that one needs a closure because Laravel’s validation machinery throwsValidationExceptionitself, 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.
The ApiError escape hatch
Section titled “The ApiError escape hatch”<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 statusthrow new ApiError($errorData, 451); // any other codeThree 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 adefault/4XX/5XXwildcard (which names no single concrete status to throw) gets no generated method; throwApiErrorwith the Data class (or anyResponsable/Arrayable/JsonSerializablevalue) 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
ApiErrorfills 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.
Testing the shape
Section titled “Testing the shape”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.
Caveats
Section titled “Caveats”- 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 acomponents.responsesentry’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.phpand 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-renderingApiErrorwith nobootstrap/app.phpclosure at all. Reach for that first (see Throwing other error statuses); theApiErrorcarrier covers anything a generated factory does not, and arenderclosure per exception type stays available when you want the framework-level hook instead.
Related pages
Section titled “Related pages”- Contract testing for asserting the 422 body against the spec per test.
- Server scaffold for where the generated validation runs and what it throws.
- Limitations for the response-side validation boundary this recipe mitigates.
- Generated output for the full Data class reference.