API Versioning Strategies in Laravel

· 7 min read

Your API is a contract. When you change it, clients break. API versioning lets you evolve your API while keeping old clients functional.

Three Versioning Strategies

Strategy Example Pros Cons
URI-based /api/v1/users Simple, visible, cacheable URL pollution
Header-based Accept: application/vnd.api.v2+json Clean URLs Harder to test
Query param /api/users?version=2 Easy to implement Messy, cache issues

Recommendation: URI-based for simplicity. Header-based for purity. Most teams choose URI.

Strategy 1: URI-Based Versioning

Route Structure

// routes/api.php

use App\Http\Controllers\Api\V1;
use App\Http\Controllers\Api\V2;

// V1 routes (original)
Route::prefix('v1')->group(function () {
    Route::apiResource('users', V1\UserController::class);
    Route::apiResource('posts', V1\PostController::class);
    Route::get('users/{user}/posts', [V1\UserController::class, 'posts']);
});

// V2 routes (new version)
Route::prefix('v2')->group(function () {
    Route::apiResource('users', V2\UserController::class);
    Route::apiResource('posts', V2\PostController::class);
    Route::apiResource('users.posts', V2\UserPostController::class);
});

Controller Structure

app/Http/Controllers/Api/
├── V1/
│   ├── UserController.php
│   └── PostController.php
└── V2/
    ├── UserController.php
    ├── PostController.php
    └── UserPostController.php

V1 Controller

// app/Http/Controllers/Api/V1/UserController.php

namespace App\Http\Controllers\Api\V1;

use App\Http\Controllers\Controller;
use App\Http\Resources\V1\UserResource;
use App\Models\User;

class UserController extends Controller
{
    public function index()
    {
        return UserResource::collection(
            User::paginate(15)
        );
    }

    public function show(User $user)
    {
        return new UserResource($user);
    }
}

V2 Controller (Different Response)

// app/Http/Controllers/Api/V2/UserController.php

namespace App\Http\Controllers\Api\V2;

use App\Http\Controllers\Controller;
use App\Http\Resources\V2\UserResource;
use App\Models\User;

class UserController extends Controller
{
    public function index()
    {
        // V2 returns users with embedded stats
        return UserResource::collection(
            User::withCount(['posts', 'comments'])->paginate(20)
        );
    }

    public function show(User $user)
    {
        return new UserResource(
            $user->loadCount(['posts', 'comments'])
                 ->load('latestPosts')
        );
    }
}

API Resources Per Version

// app/Http/Resources/V1/UserResource.php

namespace App\Http\Resources\V1;

use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\JsonResource;

class UserResource extends JsonResource
{
    public function toArray(Request $request): array
    {
        return [
            'id' => $this->id,
            'name' => $this->name,
            'email' => $this->email,
            'created_at' => $this->created_at->toISOString(),
        ];
    }
}
// app/Http/Resources/V2/UserResource.php

namespace App\Http\Resources\V2;

use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\JsonResource;

class UserResource extends JsonResource
{
    public function toArray(Request $request): array
    {
        return [
            'id' => $this->id,
            'name' => $this->name,
            'email' => $this->email,
            'avatar_url' => $this->avatar_url, // New field in V2
            'stats' => [
                'posts_count' => $this->posts_count,
                'comments_count' => $this->comments_count,
            ],
            'created_at' => $this->created_at->toISOString(),
            'updated_at' => $this->updated_at->toISOString(),
        ];
    }
}

Strategy 2: Header-Based Versioning

Custom Middleware

// app/Http/Middleware/ApiVersion.php

namespace App\Http\Middleware;

use Closure;
use Illuminate\Http\Request;

class ApiVersion
{
    public function handle(Request $request, Closure $next): mixed
    {
        $version = $this->resolveVersion($request);

        // Store version for later use
        $request->attributes->set('api_version', $version);

        return $next($request);
    }

    private function resolveVersion(Request $request): string
    {
        // Check Accept header: application/vnd.myapp.v2+json
        $accept = $request->header('Accept', '');

        if (preg_match('/application\/vnd\.myapp\.v(\d+)\+json/', $accept, $matches)) {
            return 'v' . $matches[1];
        }

        // Check custom header
        $version = $request->header('X-API-Version');
        if ($version) {
            return 'v' . $version;
        }

        // Default to latest stable version
        return 'v1';
    }
}

Single Controller with Version Switching

// app/Http/Controllers/Api/UserController.php

class UserController extends Controller
{
    public function show(Request $request, User $user)
    {
        $version = $request->attributes->get('api_version', 'v1');

        $resourceClass = match ($version) {
            'v2' => \App\Http\Resources\V2\UserResource::class,
            default => \App\Http\Resources\V1\UserResource::class,
        };

        return new $resourceClass($user);
    }
}

Sharing Code Between Versions

Don't duplicate everything. Use inheritance or composition:

Base Controller Pattern

// app/Http/Controllers/Api/BaseUserController.php

namespace App\Http\Controllers\Api;

use App\Http\Controllers\Controller;
use App\Models\User;

abstract class BaseUserController extends Controller
{
    protected function findUser(int $id): User
    {
        return User::findOrFail($id);
    }

    protected function paginateUsers(int $perPage = 15)
    {
        return User::latest()->paginate($perPage);
    }
}
// app/Http/Controllers/Api/V2/UserController.php

namespace App\Http\Controllers\Api\V2;

use App\Http\Controllers\Api\BaseUserController;
use App\Http\Resources\V2\UserResource;

class UserController extends BaseUserController
{
    public function index()
    {
        return UserResource::collection(
            $this->paginateUsers(20)->load('latestPosts')
        );
    }
}

Action-Based Sharing

// app/Actions/GetUserList.php — shared between versions

class GetUserList
{
    public function execute(int $perPage = 15, bool $withStats = false)
    {
        $query = User::latest();

        if ($withStats) {
            $query->withCount(['posts', 'comments']);
        }

        return $query->paginate($perPage);
    }
}
// V1 controller
public function index(GetUserList $action)
{
    return V1\UserResource::collection($action->execute(15));
}

// V2 controller
public function index(GetUserList $action)
{
    return V2\UserResource::collection($action->execute(20, withStats: true));
}

Deprecation Strategy

Step 1: Document Deprecation

// app/Http/Middleware/DeprecatedApiVersion.php

class DeprecatedApiVersion
{
    public function handle(Request $request, Closure $next, string $sunsetDate): mixed
    {
        $response = $next($request);

        $response->headers->set('Deprecation', 'true');
        $response->headers->set('Sunset', $sunsetDate);
        $response->headers->set('Link', '<https://api.example.com/docs/migration-v2>; rel="successor-version"');

        return $response;
    }
}
// routes/api.php
Route::prefix('v1')
    ->middleware('deprecated:2026-12-31')
    ->group(function () {
        // V1 routes
    });

Step 2: Track V1 Usage

class TrackApiVersionUsage
{
    public function handle(Request $request, Closure $next): mixed
    {
        $version = $request->attributes->get('api_version', 'unknown');

        // Log or emit metric
        Log::channel('api-metrics')->info('API call', [
            'version' => $version,
            'path' => $request->path(),
            'client' => $request->header('User-Agent'),
        ]);

        return $next($request);
    }
}

Step 3: Sunset

Phase 1: Announce deprecation (Deprecation header, docs, email)
Phase 2: Return warnings in response body
Phase 3: Rate-limit V1 more aggressively
Phase 4: Return 410 Gone for V1

When to Version

New version needed:

  • Removing a field from response
  • Changing a field's type (string → object)
  • Changing URL structure
  • Changing authentication method

No new version needed (backward compatible):

  • Adding new fields to response
  • Adding new endpoints
  • Adding optional query parameters
  • Internal bug fixes

Testing Multiple Versions

class ApiVersioningTest extends TestCase
{
    public function test_v1_returns_flat_user(): void
    {
        $user = User::factory()->create();

        $this->getJson("/api/v1/users/{$user->id}")
            ->assertOk()
            ->assertJsonStructure([
                'data' => ['id', 'name', 'email', 'created_at'],
            ])
            ->assertJsonMissing(['stats']);
    }

    public function test_v2_returns_user_with_stats(): void
    {
        $user = User::factory()->hasPosts(3)->create();

        $this->getJson("/api/v2/users/{$user->id}")
            ->assertOk()
            ->assertJsonStructure([
                'data' => [
                    'id', 'name', 'email', 'avatar_url',
                    'stats' => ['posts_count', 'comments_count'],
                    'created_at', 'updated_at',
                ],
            ]);
    }

    public function test_v1_deprecated_header(): void
    {
        $this->getJson('/api/v1/users')
            ->assertHeader('Deprecation', 'true')
            ->assertHeader('Sunset');
    }
}

API Versioning Decision Framework

Do you need to break the current API contract?
├── No → Don't create a new version. Add fields/endpoints instead.
└── Yes →
    Small team, few clients?
    ├── Yes → URI-based versioning (simplest)
    └── No →
        Need clean URLs for public API?
        ├── Yes → Header-based
        └── No → URI-based (still the best default)

Conclusion

Start with URI-based versioning — it's the simplest and most explicit. Share business logic through Actions or base classes. Version your API Resources, not your models.

Rules of thumb:

  1. Don't version until you need to — each version is maintenance burden
  2. Keep versions to a minimum (2-3 max active) — each needs tests, docs, maintenance
  3. Share code between versions aggressively — duplicate resource format, share business logic
  4. Set sunset dates and enforce them — old versions must die
  5. Track usage — know who uses what to communicate migration timeline
  6. Design additively — add fields instead of changing/removing, reducing the need for breaking changes

Comments