0

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]),
    };
});