Skip to content
~/ashwin-shresthaLoading

000%

All posts

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

  1. Validation lives in Form Requests, and controllers use validated().
  2. API routes always answer in JSON, whatever the client sent.
  3. Every error has message, and non-field errors also have a stable code.
  4. Status codes mean what they say.
  5. Production hides stack traces and logs an id instead.
  6. 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.