APIs evolve. New features are added, old endpoints deprecated, and breaking changes sometimes become necessary. API versioning gives you a way to make changes without breaking existing clients. This guide covers versioning strategies, implementation patterns, and migration approaches.
Why Version APIs?
The Compatibility Challenge
Once an API is public, changing it affects all consumers:
- Breaking changes break integrations
- Clients may not update immediately
- Different clients may need different versions
When to Version
Breaking changes:
- Removing endpoints or fields
- Changing field types or formats
- Changing error response structures
- Altering authentication requirements
Non-breaking changes (no version needed):
- Adding new endpoints
- Adding optional fields to responses
- Adding optional request parameters
- Performance improvements
Versioning Strategies
URL Path Versioning
Version in the URL path. This is the most common approach because it is explicit and easy to understand.
GET /api/v1/users
GET /api/v2/users
Pros:
- Explicit and visible
- Easy to route and implement
- Simple for clients to understand
- Easy caching
Cons:
- "Ugly" URLs
- Large codebases for many versions
The visibility is actually a significant advantage. When debugging, you can immediately see which API version a request targets just by looking at the URL.
Laravel Implementation:
Setting up URL path versioning in Laravel is straightforward using route groups.
// routes/api.php
Route::prefix('v1')->group(function () {
Route::apiResource('users', Api\V1\UserController::class);
});
Route::prefix('v2')->group(function () {
Route::apiResource('users', Api\V2\UserController::class);
});
This approach keeps each version's logic completely separate, making it easy to maintain and eventually deprecate old versions.
Header Versioning
Version in custom header. This keeps URLs clean but makes the version less discoverable.
GET /api/users
Accept-Version: v2
Or using Accept header:
GET /api/users
Accept: application/vnd.myapp.v2+json
Pros:
- Clean URLs
- Follows HTTP semantics
- Version separate from resource
Cons:
- Less visible
- Harder to test in browser
- Caching more complex
While this approach is more RESTful in theory, the practical challenges often outweigh the cleaner URLs.
Laravel Implementation:
Use middleware to extract the version and make it available to controllers.
// app/Http/Middleware/ApiVersion.php
class ApiVersion
{
public function handle(Request $request, Closure $next): Response
{
$version = $request->header('Accept-Version', 'v1');
$request->attributes->set('api_version', $version);
return $next($request);
}
}
// Controller
public function index(Request $request)
{
$version = $request->attributes->get('api_version');
return match($version) {
'v2' => $this->indexV2($request),
default => $this->indexV1($request),
};
}
The match expression provides clean branching based on version, defaulting to v1 for unspecified versions.
Query Parameter Versioning
Version as query parameter. This is the simplest to implement but pollutes the query string.
GET /api/users?version=2
Pros:
- Easy to implement
- Optional versioning
- Easy to test
Cons:
- Pollutes query string
- Can conflict with other parameters
- Caching challenges
Content Negotiation
Full content negotiation provides the most RESTful approach.
GET /api/users
Accept: application/vnd.myapp+json; version=2
Pros:
- Most RESTful approach
- Flexible media types
- Supports multiple representations
Cons:
- Complex to implement
- Harder for clients
- Less common
Implementation Patterns
Controller Per Version
Separate controllers for each version. This provides the cleanest separation but can lead to code duplication. You'll organize your controllers into version-specific directories:
app/Http/Controllers/Api/
├── V1/
│ ├── UserController.php
│ └── OrderController.php
└── V2/
├── UserController.php
└── OrderController.php
This structure makes it obvious which code belongs to which version. Each version has its own controller with its own logic.
// V1/UserController.php
namespace App\Http\Controllers\Api\V1;
class UserController extends Controller
{
public function show(User $user): UserResource
{
return new UserResource($user);
}
}
// V2/UserController.php
namespace App\Http\Controllers\Api\V2;
class UserController extends Controller
{
public function show(User $user): UserResource
{
// V2 includes additional relations
$user->load(['profile', 'preferences']);
return new UserResource($user);
}
}
When logic is similar between versions, extract shared code to a base controller or service class to avoid duplication.
Resource Per Version
API Resources handle version differences. This keeps controllers slim while moving transformation logic to dedicated classes.
// app/Http/Resources/V1/UserResource.php
namespace App\Http\Resources\V1;
class UserResource extends JsonResource
{
public function toArray(Request $request): array
{
return [
'id' => $this->id,
'name' => $this->name,
'email' => $this->email,
];
}
}
// app/Http/Resources/V2/UserResource.php
namespace App\Http\Resources\V2;
class UserResource extends JsonResource
{
public function toArray(Request $request): array
{
return [
'id' => $this->id,
'full_name' => $this->name, // Renamed field
'email_address' => $this->email, // Renamed field
'profile' => new ProfileResource($this->whenLoaded('profile')),
'created_at' => $this->created_at->toIso8601String(),
];
}
}
Notice how V2 renames fields for consistency and adds new nested resources. The whenLoaded method prevents N+1 queries by only including relationships that were eager loaded.
Transformer Pattern
Single controller with version-aware transformers. This centralizes the version logic in transformer selection.
class UserController extends Controller
{
public function show(Request $request, User $user)
{
$transformer = $this->getTransformer($request);
return $transformer->transform($user);
}
private function getTransformer(Request $request): UserTransformer
{
$version = $request->header('Api-Version', '1');
return match($version) {
'2' => new UserTransformerV2(),
default => new UserTransformerV1(),
};
}
}
This pattern works well when you want to avoid controller duplication but still need version-specific output formatting.
Deprecation Strategy
Sunset Header
Announce deprecation timeline using standard HTTP headers. This lets clients programmatically detect deprecation.
return response()->json($data)
->header('Sunset', 'Sat, 31 Dec 2024 23:59:59 GMT')
->header('Deprecation', 'true')
->header('Link', '</api/v2/users>; rel="successor-version"');
The Link header with rel="successor-version" tells clients exactly where to migrate.
Warning in Response
Include deprecation warning in the response body for visibility.
{
"data": { ... },
"meta": {
"api_version": "v1",
"deprecated": true,
"sunset_date": "2024-12-31",
"upgrade_guide": "https://docs.example.com/api/migration/v1-to-v2"
}
}
Logging Deprecated Usage
Track who uses deprecated endpoints so you can proactively reach out to clients before sunset.
class DeprecationLogger
{
public function handle(Request $request, Closure $next): Response
{
$response = $next($request);
if ($this->isDeprecated($request)) {
Log::channel('deprecation')->info('Deprecated endpoint used', [
'endpoint' => $request->path(),
'client_id' => $request->user()?->id,
'api_key' => $request->header('X-Api-Key'),
]);
}
return $response;
}
}
This data helps you understand which clients need migration support and provides leverage for eventually sunsetting the old version.
Migration Guide
Document Changes
Clear documentation is essential for smooth migrations. Create a migration guide that covers every breaking change.
# API v1 to v2 Migration Guide
## Breaking Changes
### User Resource
| v1 Field | v2 Field | Notes |
|----------|----------|-------|
| `name` | `full_name` | Renamed for clarity |
| `email` | `email_address` | Renamed for consistency |
| - | `profile` | New nested object |
### Endpoints
| v1 Endpoint | v2 Endpoint | Notes |
|-------------|-------------|-------|
| GET /users | GET /users | Response format changed |
| POST /users | POST /users | Request body unchanged |
| DELETE /users/:id | DELETE /users/:id | **Removed** - Use PATCH to deactivate |
## Code Examples
### Before (v1)
```javascript
const user = await api.get('/api/v1/users/1');
console.log(user.name);
After (v2)
const user = await api.get('/api/v2/users/1');
console.log(user.full_name);
### Parallel Running Period
Run both versions simultaneously during the migration period. This gives clients time to update while you monitor adoption.
```php
// Both versions active
Route::prefix('v1')->group(base_path('routes/api_v1.php'));
Route::prefix('v2')->group(base_path('routes/api_v2.php'));
// V1 returns deprecation headers
// V2 is the current version
Client Libraries
Provide SDKs that handle versioning automatically. This reduces friction for your clients and ensures they use the API correctly.
// PHP SDK
$client = new ApiClient([
'api_key' => 'xxx',
'version' => '2', // Default to latest
]);
$users = $client->users()->list();
Good SDKs abstract away version details while providing a clear upgrade path when new versions are released.
Best Practices
Minimize Versions
- Only create new versions for breaking changes
- Add features to existing versions when possible
- Support maximum 2-3 versions simultaneously
Version What Matters
Not everything needs versioning. Operational endpoints like health checks should remain stable.
/api/v1/users # Versioned
/api/health # Not versioned (operational)
/api/docs # Not versioned (meta)
Clear Documentation
- Document all versions
- Mark deprecated endpoints clearly
- Provide migration guides
- Include sunset dates
Consistent Versioning
Include version information in every response so clients can verify they are hitting the expected version.
// Response includes version info
{
"data": { ... },
"meta": {
"api_version": "2.0.0",
"request_id": "abc-123"
}
}
Test All Versions
Maintain test coverage for all supported versions. Use data providers to run the same tests against multiple versions.
class UserApiTest extends TestCase
{
/**
* @dataProvider versionProvider
*/
public function test_user_endpoint_returns_correct_structure($version)
{
$response = $this->getJson("/api/{$version}/users/1");
$response->assertStatus(200);
$this->assertExpectedStructure($version, $response->json());
}
public static function versionProvider(): array
{
return [
'v1' => ['v1'],
'v2' => ['v2'],
];
}
}
The data provider pattern ensures you test both versions with minimal code duplication.
Common Mistakes
These anti-patterns appear frequently in APIs and cause unnecessary pain for both providers and consumers.
Too Many Versions
Problem: Maintaining v1, v2, v3, v4... Solution: Deprecate aggressively, support 2-3 max
Breaking Without Versioning
Problem: Changing v1 behavior Solution: Any breaking change needs new version or feature flag
Inconsistent Across Endpoints
Problem: /users is v2, /orders is still v1 style Solution: Version the entire API consistently
No Sunset Date
Problem: Old versions live forever Solution: Announce sunset dates, enforce them
Avoiding these mistakes requires discipline and clear policies, but the payoff is an API that clients can trust.
Conclusion
API versioning is about managing change while maintaining trust with your consumers. URL path versioning is the most common and practical approach for most teams. Support multiple versions temporarily, communicate deprecation clearly, and provide migration guides. The goal is evolution without breakage;giving clients time to adapt while moving your API forward.