REST API Development

API Versioning Strategies

25 min Lesson 15 of 35

Introduction to API Versioning

API versioning is essential for maintaining backward compatibility while evolving your API over time. As your application grows, you'll need to make breaking changes, add new features, or deprecate old functionality—all while ensuring existing clients continue to work. In this comprehensive lesson, we'll explore different versioning strategies, their trade-offs, and how to implement them effectively in Laravel.

Why API Versioning Matters

Versioning allows you to evolve your API without breaking existing integrations:

Benefits of API Versioning:
  • Backward Compatibility: Existing clients continue to work while new features are added
  • Gradual Migration: Clients can upgrade at their own pace
  • Breaking Changes: Allows introducing changes that would otherwise break existing integrations
  • Feature Evolution: Test new features without affecting production clients
  • Clear Deprecation Path: Provides time to sunset old versions
  • Multiple Client Support: Different clients can use different API versions

When to Version Your API

Not all changes require a new version. Understanding when to version is crucial:

// NON-BREAKING CHANGES (no new version needed): ✓ Adding new endpoints ✓ Adding new optional request parameters ✓ Adding new fields to responses ✓ Adding new error codes ✓ Making required fields optional ✓ Relaxing validation rules // BREAKING CHANGES (require new version): ✗ Removing endpoints ✗ Removing request parameters or response fields ✗ Changing field types (string to number) ✗ Changing response structure ✗ Renaming fields ✗ Making optional fields required ✗ Changing authentication methods ✗ Changing URL structures

Versioning Strategies Overview

There are four main API versioning strategies, each with its own advantages:

1. URI Versioning (Most Common) - Example: /api/v1/posts, /api/v2/posts - Pros: Simple, visible, easy to test - Cons: Violates REST principles, clutters URLs 2. Header Versioning - Example: Accept: application/vnd.myapi.v2+json - Pros: Clean URLs, follows REST principles - Cons: Less discoverable, harder to test in browser 3. Query Parameter Versioning - Example: /api/posts?version=2 - Pros: Simple, easy to test - Cons: Can be ignored, not RESTful 4. Subdomain Versioning - Example: v1.api.example.com, v2.api.example.com - Pros: Clear separation, easy load balancing - Cons: Infrastructure complexity, SSL certificates
Recommendation: URI versioning is the most popular and practical approach for most applications. It's simple, clear, and works well with existing tooling.

URI Versioning Implementation

The most straightforward approach: include version in the URL path.

Basic URI Versioning Setup

<?php // routes/api.php use Illuminate\Support\Facades\Route; // Version 1 routes Route::prefix('v1')->group(function () { Route::get('/posts', [App\Http\Controllers\Api\V1\PostController::class, 'index']); Route::get('/posts/{id}', [App\Http\Controllers\Api\V1\PostController::class, 'show']); Route::post('/posts', [App\Http\Controllers\Api\V1\PostController::class, 'store']); }); // Version 2 routes with namespace Route::prefix('v2')->group(function () { Route::get('/posts', [App\Http\Controllers\Api\V2\PostController::class, 'index']); Route::get('/posts/{id}', [App\Http\Controllers\Api\V2\PostController::class, 'show']); Route::post('/posts', [App\Http\Controllers\Api\V2\PostController::class, 'store']); }); // Usage: // GET /api/v1/posts // GET /api/v2/posts </pre>

Organized Controller Structure

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

V1 Controller Example

<?php // app/Http/Controllers/Api/V1/PostController.php namespace App\Http\Controllers\Api\V1; use App\Http\Controllers\Controller; use App\Http\Resources\V1\PostResource; use App\Models\Post; use Illuminate\Http\JsonResponse; class PostController extends Controller { public function index(): JsonResponse { $posts = Post::with('author')->paginate(15); return response()->json([ 'data' => PostResource::collection($posts->items()), 'total' => $posts->total(), 'page' => $posts->currentPage(), ]); } public function show($id): JsonResponse { $post = Post::with('author')->findOrFail($id); return response()->json(new PostResource($post)); } } </pre>

V2 Controller with Breaking Changes

<?php // app/Http/Controllers/Api/V2/PostController.php namespace App\Http\Controllers\Api\V2; use App\Http\Controllers\Controller; use App\Http\Resources\V2\PostResource; use App\Models\Post; use Illuminate\Http\JsonResponse; class PostController extends Controller { public function index(): JsonResponse { $posts = Post::with('author', 'tags')->cursorPaginate(15); // V2 uses different response structure return response()->json([ 'success' => true, 'posts' => PostResource::collection($posts->items()), 'pagination' => [ 'next_cursor' => $posts->nextCursor()?->encode(), 'prev_cursor' => $posts->previousCursor()?->encode(), 'has_more' => $posts->hasMorePages(), ], ]); } public function show(string $id): JsonResponse { // V2 uses HashIds instead of numeric IDs $post = Post::where('hash_id', $id) ->with('author', 'tags', 'comments') ->firstOrFail(); return response()->json([ 'success' => true, 'post' => new PostResource($post), ]); } } </pre>

Version-Specific Resources

<?php // app/Http/Resources/V1/PostResource.php namespace App\Http\Resources\V1; use Illuminate\Http\Resources\Json\JsonResource; class PostResource extends JsonResource { public function toArray($request): array { return [ 'id' => $this->id, 'title' => $this->title, 'content' => $this->content, 'author' => $this->author->name, 'created_at' => $this->created_at->toIso8601String(), ]; } } // app/Http/Resources/V2/PostResource.php namespace App\Http\Resources\V2; use Illuminate\Http\Resources\Json\JsonResource; class PostResource extends JsonResource { public function toArray($request): array { return [ 'hash_id' => $this->hash_id, // Changed from numeric id 'title' => $this->title, 'content' => $this->content, 'excerpt' => $this->excerpt, // New field 'author' => [ // Changed from string to object 'id' => $this->author->id, 'name' => $this->author->name, 'avatar' => $this->author->avatar_url, ], 'tags' => $this->tags->pluck('name'), // New field 'published_at' => $this->published_at?->toIso8601String(), // Renamed ]; } } </pre>

Header-Based Versioning

Use custom headers or Accept header for version specification:

<?php // app/Http/Middleware/ApiVersion.php namespace App\Http\Middleware; use Closure; use Illuminate\Http\Request; class ApiVersion { public function handle(Request $request, Closure $next) { // Check custom header $version = $request->header('X-API-Version', 'v1'); // Or parse Accept header // Accept: application/vnd.myapi.v2+json if ($request->header('Accept')) { preg_match('/v(\d+)/', $request->header('Accept'), $matches); $version = isset($matches[1]) ? "v{$matches[1]}" : 'v1'; } // Store version in request $request->merge(['api_version' => $version]); return $next($request); } } // Register middleware // app/Http/Kernel.php protected $middlewareGroups = [ 'api' => [ \App\Http\Middleware\ApiVersion::class, // ... other middleware ], ]; // Dynamic controller routing based on version Route::get('/posts', function (Request $request) { $version = $request->input('api_version', 'v1'); $controller = "App\\Http\\Controllers\\Api\\{$version}\\PostController"; return app($controller)->index($request); }); // Client usage: // GET /api/posts // Headers: X-API-Version: v2 // Or: Accept: application/vnd.myapi.v2+json </pre>

Query Parameter Versioning

Simplest approach but less recommended:

<?php // routes/api.php Route::get('/posts', function (Request $request) { $version = $request->query('version', 'v1'); $controller = match ($version) { 'v2' => App\Http\Controllers\Api\V2\PostController::class, default => App\Http\Controllers\Api\V1\PostController::class, }; return app($controller)->index($request); }); // Usage: // GET /api/posts?version=v1 // GET /api/posts?version=v2 </pre>

Shared Code Between Versions

Avoid code duplication by sharing common logic:

<?php // app/Http/Controllers/Api/BasePostController.php namespace App\Http\Controllers\Api; use App\Http\Controllers\Controller; use App\Models\Post; abstract class BasePostController extends Controller { protected function getPost($id): Post { return Post::with($this->getRelations())->findOrFail($id); } protected function getPosts() { return Post::with($this->getRelations()) ->when(request('status'), fn($q, $status) => $q->where('status', $status)) ->latest(); } abstract protected function getRelations(): array; } // V1 Controller namespace App\Http\Controllers\Api\V1; use App\Http\Controllers\Api\BasePostController; class PostController extends BasePostController { protected function getRelations(): array { return ['author']; } public function index() { $posts = $this->getPosts()->paginate(15); return response()->json($posts); } } // V2 Controller namespace App\Http\Controllers\Api\V2; use App\Http\Controllers\Api\BasePostController; class PostController extends BasePostController { protected function getRelations(): array { return ['author', 'tags', 'comments']; // More relations in V2 } public function index() { $posts = $this->getPosts()->cursorPaginate(15); // Different pagination return response()->json([ 'success' => true, 'data' => $posts, ]); } } </pre>

Version Negotiation

Automatically route requests to the correct version:

<?php // app/Http/Middleware/ApiVersionNegotiation.php namespace App\Http\Middleware; use Closure; use Illuminate\Http\Request; class ApiVersionNegotiation { protected array $supportedVersions = ['v1', 'v2']; protected string $defaultVersion = 'v1'; protected string $latestVersion = 'v2'; public function handle(Request $request, Closure $next) { $version = $this->negotiateVersion($request); // Set version globally config(['api.version' => $version]); $request->merge(['api_version' => $version]); return $next($request); } protected function negotiateVersion(Request $request): string { // Check URI first if (preg_match('/\/(v\d+)\//', $request->path(), $matches)) { return $this->validateVersion($matches[1]); } // Check header if ($header = $request->header('X-API-Version')) { return $this->validateVersion($header); } // Check query parameter if ($query = $request->query('version')) { return $this->validateVersion($query); } // Default version return $this->defaultVersion; } protected function validateVersion(string $version): string { $version = strtolower($version); if (!in_array($version, $this->supportedVersions)) { abort(400, "Unsupported API version: {$version}. Supported versions: " . implode(', ', $this->supportedVersions)); } return $version; } } </pre>

Deprecation Strategy

Properly communicate version deprecation to clients:

<?php // app/Http/Middleware/ApiDeprecation.php namespace App\Http\Middleware; use Closure; use Illuminate\Http\Request; use Illuminate\Support\Facades\Log; class ApiDeprecation { protected array $deprecatedVersions = [ 'v1' => [ 'deprecated_at' => '2024-01-01', 'sunset_at' => '2024-06-01', 'message' => 'API v1 is deprecated and will be removed on 2024-06-01. Please migrate to v2.', ], ]; public function handle(Request $request, Closure $next) { $version = $request->input('api_version', config('api.version')); if (isset($this->deprecatedVersions[$version])) { $info = $this->deprecatedVersions[$version]; // Log deprecation usage Log::warning("Deprecated API version used: {$version}", [ 'user_id' => auth()->id(), 'endpoint' => $request->path(), 'ip' => $request->ip(), ]); // Add deprecation headers $response = $next($request); return $response->withHeaders([ 'X-API-Deprecated' => 'true', 'X-API-Sunset-Date' => $info['sunset_at'], 'X-API-Deprecation-Info' => $info['message'], 'Warning' => '299 - "' . $info['message'] . '"' ]); } return $next($request); } } </pre>

Testing Versioned APIs

Comprehensive tests for all API versions:

<?php // tests/Feature/Api/V1/PostTest.php namespace Tests\Feature\Api\V1; use App\Models\Post; use App\Models\User; use Illuminate\Foundation\Testing\RefreshDatabase; use Tests\TestCase; class PostTest extends TestCase { use RefreshDatabase; public function test_v1_returns_correct_response_structure() { Post::factory()->count(3)->create(); $response = $this->getJson('/api/v1/posts'); $response->assertOk() ->assertJsonStructure([ 'data' => [ '*' => ['id', 'title', 'content', 'author', 'created_at'] ], 'total', 'page', ]); // Ensure numeric IDs are used in V1 $this->assertIsInt($response->json('data.0.id')); } public function test_v1_deprecation_headers() { $response = $this->getJson('/api/v1/posts'); $response->assertHeader('X-API-Deprecated', 'true') ->assertHeader('X-API-Sunset-Date'); } } // tests/Feature/Api/V2/PostTest.php namespace Tests\Feature\Api\V2; use App\Models\Post; use Illuminate\Foundation\Testing\RefreshDatabase; use Tests\TestCase; class PostTest extends TestCase { use RefreshDatabase; public function test_v2_returns_correct_response_structure() { Post::factory()->count(3)->create(); $response = $this->getJson('/api/v2/posts'); $response->assertOk() ->assertJsonStructure([ 'success', 'posts' => [ '*' => [ 'hash_id', 'title', 'content', 'excerpt', 'author' => ['id', 'name', 'avatar'], 'tags', 'published_at' ] ], 'pagination', ]); // Ensure hash IDs are used in V2 $this->assertIsString($response->json('posts.0.hash_id')); } public function test_v2_no_deprecation_headers() { $response = $this->getJson('/api/v2/posts'); $response->assertHeaderMissing('X-API-Deprecated'); } } </pre>

Documentation for Multiple Versions

Maintain separate documentation for each version:

docs/ ├── api/ │ ├── v1/ │ │ ├── endpoints.md │ │ ├── authentication.md │ │ └── examples.md │ └── v2/ │ ├── endpoints.md │ ├── authentication.md │ ├── examples.md │ └── migration-guide.md # How to migrate from v1 to v2 // Add version selector to API docs // public/docs/index.html <select id="version-selector"> <option value="v2">v2 (Latest)</option> <option value="v1">v1 (Deprecated)</option> </select>
Practice Exercise:
  1. Implement URI-based versioning for a User API with V1 using numeric IDs and V2 using UUID/HashIds
  2. Create a middleware that detects API version from headers and dynamically routes to the correct controller
  3. Build a deprecation system that logs deprecated version usage and sends warning headers to clients
  4. Implement version-specific API resources that transform the same model data differently for V1 and V2
  5. Write comprehensive tests that verify response structure, field types, and deprecation headers for both versions

Best Practices

API Versioning Best Practices:
  • Version only when necessary: Not all changes require a new version—avoid breaking changes when possible
  • Support at least 2 versions: Current and previous version for gradual migration
  • Communicate deprecation: Give clients 6-12 months notice before sunsetting old versions
  • Document thoroughly: Provide migration guides between versions
  • Use semantic versioning: v1, v2, v3 (not v1.2.3 for REST APIs)
  • Default to latest stable: If no version specified, use latest stable (not bleeding edge)
  • Monitor usage: Track which versions are being used to plan deprecation
  • Test all versions: Maintain tests for all supported versions

Summary

In this lesson, you've mastered API versioning strategies in Laravel. You now understand when versioning is necessary, the differences between URI, header, and query parameter versioning, how to implement version-specific controllers and resources, techniques for sharing code between versions, deprecation strategies with proper client communication, and testing approaches for multiple API versions. Proper versioning is essential for maintaining professional APIs that can evolve over time without breaking existing integrations, allowing you to improve your API while respecting your clients' timelines.