In one sentence

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.

Why it matters

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.

The big picture

Two versions, one app

๐Ÿ“ฑ/api/v1/postsold mobile app โ†’ V1\PostController + V1\PostResource
๐Ÿ’ป/api/v2/postsnew web app โ†’ V2\PostController + V2\PostResource
๐Ÿ—„๏ธShared models & databaseApp\Models\Post is used by both
Versions differ in their routes, controllers and resources. The models and data underneath stay shared.
app/Http/
โ”œโ”€โ”€ Controllers/Api/
โ”‚   โ”œโ”€โ”€ V1/PostController.php
โ”‚   โ””โ”€โ”€ V2/PostController.php
โ””โ”€โ”€ Resources/
    โ”œโ”€โ”€ V1/PostResource.php   โ† "title", "body"
    โ””โ”€โ”€ V2/PostResource.php   โ† "headline", "content", "author"
Step by step

Version the API, then tidy the errors

  1. Put the version in the URL

    The simplest strategy is a URL prefix. Group each version's routes under v1 or v2. Combined with the automatic /api prefix, 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 exist

    Some 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.

  2. 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 get headline. Nobody breaks.

  3. Know Laravel's default JSON errors

    When a request sends Accept: application/json, Laravel already turns common exceptions into JSON. A missing model becomes a 404:

    
              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=true you will also see the exception class and stack trace. Make sure it's false in production.

  4. Validation errors come back as 422 JSON

    When $request->validate() or a Form Request fails on a JSON request, Laravel responds with 422. There's a summary message plus an errors object 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.

  5. Customise errors in bootstrap/app.php

    In Laravel 11/12, exception handling is configured with withExceptions. Two useful settings: always render JSON for api/* 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 render callback (as for web requests here) falls back to Laravel's default handling.

  6. 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." }
            
Common mistakes

Avoid this, do that

โŒ Avoid

          // Changed in place, no version bump
          'headline' => $this->title,
        

Renaming a field in a live API silently breaks every client that reads title.

โœ… Do

          // V1\PostResource keeps 'title'
          // V2\PostResource introduces 'headline'
        

Add new fields freely, but put breaking changes in a new version.

โŒ Avoid

          try { /* ... */ } catch (\Exception $e) {
              return ['error' => $e->getMessage()];
          }
        

A different error shape per endpoint, a 200 status, and possibly leaked internals.

โœ… Do

          $exceptions->render(function (PaymentFailed $e) {
              return response()->json(['message' => 'Payment failed.'], 402);
          });
        

Let exceptions bubble up and shape them in one place: withExceptions.

๐Ÿ’ก Pro tip: deprecate politely

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.

๐Ÿ‹๏ธ Try it yourself
  1. Move your existing posts API into Api/V1 and serve it under /api/v1/posts.
  2. Create a V2\PostResource that renames title to headline and nests the author.
  3. Request /api/v1/posts/999 and /api/v2/posts/999; both should return the same 404 JSON.
  4. POST an empty body and inspect the 422 errors object.
  5. Add shouldRenderJsonWhen and confirm a request without the Accept header still gets JSON.
Recap

Key takeaways

Check yourself

Mini quiz