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