Laravel API validation that fails predictably
A front end can only be as reliable as the errors your API returns. How to make every failure in a Laravel REST API look the same.
A client can handle an error it expects. It cannot handle one it has never seen. The single biggest improvement you can make to a Laravel REST API is making every failure, whether it is a validation problem, a missing record or a server crash, come back in the same shape.
Start with Form Requests
Keep validation out of controllers. A Form Request holds the rules for one action and runs before your controller code does.
class StoreOrderRequest extends FormRequest
{
public function authorize(): bool
{
return $this->user()->can('create', Order::class);
}
public function rules(): array
{
return [
'items' => ['required', 'array', 'min:1'],
'items.*.product_id' => ['required', 'integer', 'exists:products,id'],
'items.*.quantity' => ['required', 'integer', 'min:1', 'max:50'],
'coupon' => ['nullable', 'string', 'max:32'],
];
}
}
The controller then receives only data that has already passed validation, through $request->validated(). Never read $request->all() after validating; it still contains fields you did not approve.
Make sure errors are JSON
Laravel decides whether to return JSON or redirect based on the request. If a client forgets the Accept: application/json header, a failed validation becomes a 302 redirect to an HTML page, which is useless to an API consumer. For an API-only route group, force the expectation:
// bootstrap/app.php
->withExceptions(function (Exceptions $exceptions) {
$exceptions->shouldRenderJsonWhen(
fn (Request $request) => $request->is('api/*') || $request->expectsJson()
);
})
One error shape for everything
By default a failed validation returns HTTP 422 with message and an errors object keyed by field. That is already a good contract, so make the other errors match it:
{
"message": "The given data was invalid.",
"errors": { "items.0.quantity": ["The items.0.quantity must be at least 1."] }
}
For errors that are not about a field, keep the same envelope with a stable machine-readable code the front end can switch on:
$exceptions->render(function (ModelNotFoundException $e, Request $request) {
if ($request->is('api/*')) {
return response()->json([
'message' => 'Resource not found.',
'code' => 'not_found',
], 404);
}
});
Messages are for people and can change. Codes are for code and should not.
Use the right status code
- 401 when the caller is not authenticated.
- 403 when they are authenticated but not allowed.
- 404 when the resource does not exist (or you do not want to reveal it does).
- 409 when the request conflicts with current state, for example an order that is already paid.
- 422 when the request is understood but the data is invalid.
- 429 when a rate limit is hit.
Returning 200 with {"success": false} forces every client to inspect the body to learn the outcome, and breaks monitoring that counts errors by status.
Never leak internals
In production, set APP_DEBUG=false. With debug on, an unhandled exception returns a stack trace, file paths and sometimes environment details. Render unexpected exceptions as a generic 500 with an id you can search in your logs:
$exceptions->render(function (Throwable $e, Request $request) {
if ($request->is('api/*') && ! config('app.debug')) {
$id = (string) Str::uuid();
Log::error('Unhandled API exception', ['id' => $id, 'exception' => $e]);
return response()->json(['message' => 'Server error.', 'code' => 'server_error', 'id' => $id], 500);
}
});
A support message that quotes id takes seconds to trace. A screenshot of a blank page takes an afternoon.
Test the failures, not just the success
Every endpoint deserves at least one test for its unhappy path. In Postman or a feature test, assert the status, the error code and the field that failed.
public function test_quantity_must_be_positive(): void
{
$this->actingAs(User::factory()->create())
->postJson('/api/orders', ['items' => [['product_id' => 1, 'quantity' => 0]]])
->assertStatus(422)
->assertJsonValidationErrors(['items.0.quantity']);
}
A short checklist
- Validation lives in Form Requests, and controllers use
validated(). - API routes always answer in JSON, whatever the client sent.
- Every error has
message, and non-field errors also have a stablecode. - Status codes mean what they say.
- Production hides stack traces and logs an id instead.
- Each endpoint has a test for how it fails.
None of this is clever, and that is the point. A predictable API turns front-end error handling into a lookup instead of a guessing game.