Versioning lets you change your API without breaking existing apps, and consistent JSON errors let every client handle failure the same way.
๐Think of plug sockets. When a country introduces a new socket type, old appliances still need to work, so both exist side by side for a while (v1 and v2). And when the fuse blows, every house gets the same kind of breaker, not a surprise.
You can't force users to update
Old apps live forever
Some people never update their phone app. Their v1 requests must keep working after you ship v2.
One error shape
If every error has a message key, the client needs just one piece of error-handling code.
No leaked internals
Stack traces in production help attackers, not users. Clean JSON errors hide the details.
Two versions, one app
app/Http/ โโโ Controllers/Api/ โ โโโ V1/PostController.php โ โโโ V2/PostController.php โโโ Resources/ โโโ V1/PostResource.php โ "title", "body" โโโ V2/PostResource.php โ "headline", "content", "author"
Version the API, then tidy the errors
-
Put the version in the URL
The simplest strategy is a URL prefix. Group each version's routes under
v1orv2. Combined with the automatic/apiprefix, URLs become/api/v1/posts.use App\Http\Controllers\Api\V1; use App\Http\Controllers\Api\V2; Route::prefix('v1')->name('v1.')->group(function () { Route::apiResource('posts', V1\PostController::class); }); Route::prefix('v2')->name('v2.')->group(function () { Route::apiResource('posts', V2\PostController::class); });โน๏ธ Other strategies existSome APIs read the version from a header like
Accept: application/vnd.blog.v2+json. URL prefixes are easier to see, test with curl and cache, so start there. -
Namespace controllers and resources by folder
Generate classes straight into version folders. The folder becomes part of the namespace, so two classes can both be called
PostController.php artisan make:controller Api/V2/PostController --api --model=Post php artisan make:resource V2/PostResource<?php namespace App\Http\Resources\V2; class PostResource extends \Illuminate\Http\Resources\Json\JsonResource { public function toArray($request): array { return [ 'id' => $this->id, 'headline' => $this->title, // renamed in v2 'content' => $this->body, 'author' => ['id' => $this->user_id, 'name' => $this->user->name], ]; } }v1 clients keep getting
title; v2 clients getheadline. Nobody breaks. -
Know Laravel's default JSON errors
When a request sends
Accept: application/json, Laravel already turns common exceptions into JSON. A missing model becomes a404:curl -i http://127.0.0.1:8000/api/v1/posts/999 -H "Accept: application/json"{ "message": "No query results for model [App\\Models\\Post] 999." }That message exposes your model class name, which you will tidy up in step 5. With
APP_DEBUG=trueyou will also see the exception class and stack trace. Make sure it'sfalsein production. -
Validation errors come back as 422 JSON
When
$request->validate()or a Form Request fails on a JSON request, Laravel responds with422. There's a summarymessageplus anerrorsobject keyed by field.curl -X POST http://127.0.0.1:8000/api/v1/posts \ -H "Accept: application/json" -H "Content-Type: application/json" \ -H "Authorization: Bearer $TOKEN" \ -d '{"title":""}'{ "message": "The title field is required. (and 1 more error)", "errors": { "title": ["The title field is required."], "body": ["The body field is required."] } }A client can show
errors.title[0]right next to the title input. -
Customise errors in
bootstrap/app.phpIn Laravel 11/12, exception handling is configured with
withExceptions. Two useful settings: always render JSON forapi/*URLs, and give 404s a friendlier, consistent message.use Illuminate\Http\Request; use Symfony\Component\HttpKernel\Exception\NotFoundHttpException; ->withExceptions(function (Exceptions $exceptions) { $exceptions->shouldRenderJsonWhen( fn (Request $request) => $request->is('api/*') || $request->expectsJson() ); $exceptions->render(function (NotFoundHttpException $e, Request $request) { if ($request->is('api/*')) { return response()->json([ 'message' => 'Resource not found.', ], 404); } }); })Returning nothing from a
rendercallback (as for web requests here) falls back to Laravel's default handling. -
Throw meaningful errors yourself
Use
abort()for HTTP errors. It goes through the same handler, so the JSON stays consistent.abort_if($post->is_locked, 409, 'This post is locked for editing.'); abort_unless($request->user()->can('delete', $post), 403);{ "message": "This post is locked for editing." }
Avoid this, do that
// Changed in place, no version bump
'headline' => $this->title,
Renaming a field in a live API silently breaks every client that reads title.
// V1\PostResource keeps 'title'
// V2\PostResource introduces 'headline'
Add new fields freely, but put breaking changes in a new version.
try { /* ... */ } catch (\Exception $e) {
return ['error' => $e->getMessage()];
}
A different error shape per endpoint, a 200 status, and possibly leaked internals.
$exceptions->render(function (PaymentFailed $e) {
return response()->json(['message' => 'Payment failed.'], 402);
});
Let exceptions bubble up and shape them in one place: withExceptions.
When you plan to retire v1, announce a date, add a Deprecation or Sunset response header to v1 routes via middleware, and watch your logs until traffic drops to zero.
- Move your existing posts API into
Api/V1and serve it under/api/v1/posts. - Create a
V2\PostResourcethat renamestitletoheadlineand nests the author. - Request
/api/v1/posts/999and/api/v2/posts/999; both should return the same 404 JSON. - POST an empty body and inspect the
422errorsobject. - Add
shouldRenderJsonWhenand confirm a request without the Accept header still gets JSON.
Key takeaways
- Version with URL prefixes (
/api/v1,/api/v2) and matchingV1/V2folders. - Only breaking changes need a new version; adding fields is usually safe.
- Validation failures return
422withmessageand a per-fielderrorsobject. - Configure error rendering in
bootstrap/app.phpwithwithExceptions,shouldRenderJsonWhenandrender. - Keep
APP_DEBUG=falsein production so errors never leak stack traces.