DEV Community

Kamran Khan
Kamran Khan

Posted on

My API has exactly five failure shapes: the exception renders that keep every error JSON

Nothing erodes frontend trust faster than an API that answers JSON on success and HTML on failure. Your React code parses the happy path fine, then one expired token returns a Laravel error page and the whole app crashes on response.data.message being undefined — because response.data is a string of HTML.

I decided my API speaks JSON on every path, including the ones I don't control directly. That took two exception renders and three deliberate choices. Here are all five failure shapes.

1 & 2. The two renders in bootstrap/app.php

Laravel's default behaviour for an unauthenticated API request is to redirect to a login route. My API has no login route — it's JSON-only — so the default behaviour is a RouteNotFoundException, which becomes a 500 HTML page. Two renders fix the whole class of problem:

->withExceptions(function (Exceptions $exceptions) {
    // API clients don't always send "Accept: application/json" —
    // always answer JSON for /api/*.
    $exceptions->render(function (AuthenticationException $e, Request $request) {
        if ($request->is('api/*')) {
            return response()->json(['message' => 'Unauthenticated.'], 401);
        }
    });

    // When a non-JSON request hits an auth-guarded API route, Laravel's
    // Authenticate middleware tries to redirect to the "login" route
    // (which this API does not define). Answer JSON instead of a 500.
    $exceptions->render(function (RouteNotFoundException $e, Request $request) {
        if ($request->is('api/*')) {
            return response()->json(['message' => 'Unauthenticated.'], 401);
        }
    });
})
Enter fullscreen mode Exit fullscreen mode

The second one is the sneaky one. A mobile client or a curl call without the Accept header hits a guarded route, Laravel tries its web-style redirect, the route doesn't exist, and without this render your API returns a 500 for what is really a 401. I return Unauthenticated. (matching Laravel's own message, period included) so clients that already handle stock Laravel recognise it.

Note the guard: $request->is('api/*'). These renders only touch API routes. If this app ever grows web routes, they keep their normal behaviour.

3. Login failures: one flat 401

return response()->json(['message' => 'Invalid credentials'], 401);
Enter fullscreen mode Exit fullscreen mode

Same message whether the email doesn't exist or the password is wrong. Distinguishing the two is an account-enumeration gift: an attacker learns which emails have accounts. The frontend renders one message; the user who mistyped figures it out on the second try.

4. Forbidden: 403, not 404

The role middleware answers {"message": "Forbidden"} with a 403. I don't hide the endpoint — security through obscurity on your own admin API buys nothing and makes debugging miserable. A valid token with the wrong role is a real, nameable state; give it a real, nameable response.

5. Validation: Laravel's 422, untouched

I didn't customise the validation error shape at all — Laravel's {"message": "The email field is required.", "errors": {"email": [...]}} with a 422 is already a good contract. The discipline is elsewhere: it must be the only 422 shape. My self-delete guard returns {"message": "You cannot delete your own account."} with a 422 too — same status, same message key, so the frontend's error toast handles both without special cases.

And success has its own discipline: deletes return 204 with no body. Nothing to say, nothing to parse.

The rule

Enumerate your failure shapes like you enumerate your endpoints. If you can't list every status and body your API can produce, your frontend is guessing — and it's guessing wrong on exactly the paths you test least. Five shapes, all JSON, all deliberate. The frontend has one error handler, and it has never met an HTML page.


This is the error discipline I ship in my Laravel + React SaaS Starter Kit ($19 one-time) — Sanctum auth, role-based access, and an API that never surprises the frontend.

Top comments (0)