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:
- Implement URI-based versioning for a User API with V1 using numeric IDs and V2 using UUID/HashIds
- Create a middleware that detects API version from headers and dynamically routes to the correct controller
- Build a deprecation system that logs deprecated version usage and sends warning headers to clients
- Implement version-specific API resources that transform the same model data differently for V1 and V2
- 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.