Versioning an API — URL path vs header vs query string — trade-offs
Intermediate5 min read·eng-13-017
interview
Concept
API versioning — the strategy for managing breaking changes to your API while maintaining backward compatibility for existing clients.
A breaking change is any change that breaks existing clients: removing a field, renaming a field, changing a field's type, removing an endpoint, changing authentication requirements, changing behavior of an endpoint.
Non-breaking changes: Adding new fields (clients ignore them), adding new optional parameters, adding new endpoints.
Three main versioning strategies:
1. URL path versioning: /api/v1/users → /api/v2/users
- Pros: Visible in URLs, easy to test in a browser. Explicit. Cacheable (URL includes version).
- Cons: URL "purity" purists object (version is not part of the resource identity). Duplicate routes.
- Used by: Twitter, Facebook, Stripe, most major APIs. The industry standard.
2. Header versioning: API-Version: 2 or Accept: application/vnd.example.v2+json
- Pros: URL stays clean. "Correct" from a REST purist perspective (same resource, different representation).
- Cons: Cannot bookmark or share versioned URLs. Harder to test. Caching requires
Vary: API-Version. - Used by: GitHub (header versioning for some endpoints).
3. Query string versioning: /api/users?version=2 or /api/users?v=2
- Pros: Easy to implement and test. URL stays identifiable.
- Cons: Version in query string is less explicit than path. Caching may ignore query params.
- Used by: Google, Amazon (some services).
Versioning strategy recommendations:
- Use URL path versioning (
/api/v1/) — it's the most practical, explicit, and understood. - Version the whole API, not individual endpoints (simpler mental model).
- Maintain at least one old version during sunset (give clients 6-12 months).
- Deprecate via headers:
Deprecation: true,Sunset: Sat, 31 Dec 2025 23:59:59 GMT.
Code Example
php
<?php
// URL PATH VERSIONING — most common in practice
Route::prefix('api/v1')->group(function () {
Route::apiResource('users', App\Http\Controllers\Api\V1\UserController::class);
Route::apiResource('orders', App\Http\Controllers\Api\V1\OrderController::class);
});
Route::prefix('api/v2')->group(function () {
Route::apiResource('users', App\Http\Controllers\Api\V2\UserController::class);
// V2 adds: roles, permissions, new response format
});
// V1 and V2 can coexist indefinitely
// GET /api/v1/users → V1\UserController
// GET /api/v2/users → V2\UserController
// Controllers share logic via a base
namespace App\Http\Controllers\Api\V1;
class UserController extends \App\Http\Controllers\Api\BaseUserController {}
namespace App\Http\Controllers\Api\V2;
class UserController extends \App\Http\Controllers\Api\BaseUserController
{
public function show(User $user): JsonResponse // only override what changed
{
return response()->json([
'id' => $user->id,
'name' => $user->name,
'roles' => $user->roles->pluck('name'), // new in V2
]);
}
}
// HEADER VERSIONING
class VersionMiddleware
{
public function handle(Request $request, \Closure $next): mixed
{
$version = $request->header('API-Version', '1');
$request->attributes->set('api_version', $version);
$response = $next($request);
return $response->header('API-Version', $version);
}
}
// DEPRECATION HEADERS — warning clients before removing a version
class DeprecationMiddleware
{
public function handle(Request $request, \Closure $next): mixed
{
$response = $next($request);
if (str_starts_with($request->path(), 'api/v1/')) {
$response->headers->set('Deprecation', 'true');
$response->headers->set('Sunset', 'Sat, 31 Dec 2025 23:59:59 GMT');
$response->headers->set('Link', '<https://docs.example.com/v2>; rel="successor-version"');
}
return $response;
}
}
// VERSIONING VIA CONTENT TYPE (Accept header)
// Accept: application/vnd.example+json; version=2
Route::get('/users/{id}', function (int $id, Request $request) {
$accept = $request->header('Accept', '');
$version = 1;
if (preg_match('/version=(\d+)/', $accept, $m)) $version = (int) $m[1];
$user = User::findOrFail($id);
return match ($version) {
2 => response()->json(['id' => $user->id, 'displayName' => $user->name]),
default => response()->json(['id' => $user->id, 'name' => $user->name]),
};
});