API Versioning Strategies That Scale

Reverend Philip Jan 6, 2026 11 min read

Version your APIs without breaking clients. Compare URL, header, and query versioning with deprecation and migration strategies.

APIs evolve, but breaking existing clients is costly. A good versioning strategy lets you improve your API while maintaining backward compatibility for consumers who can't upgrade immediately.

Why Version APIs?

The Breaking Change Problem

When you change the structure of your API responses, existing clients that depend on that structure break. This simple field rename illustrates the problem.

This example shows how a seemingly simple change can break every client consuming your API. The field rename invalidates all existing client code.

// Original API (v1)
{
    "user": {
        "name": "John Doe",
        "email": "john@example.com"
    }
}

// New requirement: Split name into first/last
{
    "user": {
        "first_name": "John",    // Breaking change!
        "last_name": "Doe",
        "email": "john@example.com"
    }
}

// Clients expecting "name" field now break

Without versioning, you must coordinate changes with every client simultaneously, which is often impossible.

Versioning Strategies

URL Path Versioning

The most explicit approach places the version directly in the URL path. Clients can see exactly which version they're using.

URL path versioning makes the version immediately visible. You can see which version you're calling just by looking at the URL.

GET /api/v1/users/123
GET /api/v2/users/123

Implementing this in Laravel is straightforward with route groups.

This Laravel route configuration separates versions into distinct route groups. Each version can have its own controllers and logic.

// routes/api.php
Route::prefix('v1')->group(function () {
    Route::get('/users/{id}', [V1\UserController::class, 'show']);
});

Route::prefix('v2')->group(function () {
    Route::get('/users/{id}', [V2\UserController::class, 'show']);
});

Pros:

  • Clear and explicit
  • Easy to test and document
  • Cache-friendly (different URLs)

Cons:

  • URL pollution
  • Clients must change URLs to upgrade

Header Versioning

Header-based versioning keeps URLs clean by specifying the version in the Accept header. This follows content negotiation standards.

With header versioning, the URL stays clean and the version is specified in the request headers using content negotiation.

GET /api/users/123
Accept: application/vnd.myapi.v2+json

You'll need middleware to extract the version from headers and make it available to your application.

This middleware parses the version from the Accept header and makes it available throughout your application. The controller then uses it to return the appropriate response format.

// app/Http/Middleware/ApiVersionMiddleware.php
class ApiVersionMiddleware
{
    public function handle(Request $request, Closure $next)
    {
        $accept = $request->header('Accept', '');

        // Parse version from Accept header
        if (preg_match('/application\/vnd\.myapi\.v(\d+)\+json/', $accept, $matches)) {
            $version = (int) $matches[1];
        } else {
            $version = 1; // Default version
        }

        $request->attributes->set('api_version', $version);

        return $next($request);
    }
}

// Usage in controller
class UserController
{
    public function show(Request $request, int $id)
    {
        $user = User::findOrFail($id);

        $version = $request->attributes->get('api_version');

        return match ($version) {
            2 => new V2\UserResource($user),
            default => new V1\UserResource($user),
        };
    }
}

The match expression makes it easy to add new versions without complex conditional chains.

Pros:

  • Clean URLs
  • Follows HTTP content negotiation standards

Cons:

  • Harder to test in browser
  • Easy to forget the header

Query Parameter Versioning

Query parameters offer a simple approach that's easy to test but mixes concerns with other query parameters.

Query parameter versioning is the simplest approach. You can test it directly in your browser by adding the version to the URL.

GET /api/users/123?version=2

This implementation shows how straightforward query parameter versioning is. Just read the version from the query string.

class UserController
{
    public function show(Request $request, int $id)
    {
        $user = User::findOrFail($id);
        $version = $request->query('version', 1);

        return match ((int) $version) {
            2 => new V2\UserResource($user),
            default => new V1\UserResource($user),
        };
    }
}

Pros:

  • Easy to test
  • No URL path changes

Cons:

  • Query params typically for filtering, not versioning
  • Caching complications

Recommendation: URL Path for Major, Headers for Minor

For most applications, use URL paths for major versions and headers for minor refinements. This gives you the clarity of path versioning with the flexibility of header versioning.

Combine URL paths for major versions with headers for minor variations. This hybrid approach works well for most APIs.

// Major versions in URL
Route::prefix('v1')->group(function () {
    // v1.x endpoints
});

Route::prefix('v2')->group(function () {
    // v2.x endpoints
});

// Minor versions via header
// Accept: application/vnd.myapi.v2.1+json

Implementing Versioned Resources

Separate Resource Classes

Keep version-specific transformations in separate resource classes. This prevents the complexity of handling multiple versions in a single class.

Create separate resource classes for each API version. This keeps version-specific logic isolated and easy to maintain.

// app/Http/Resources/V1/UserResource.php
namespace App\Http\Resources\V1;

class UserResource extends JsonResource
{
    public function toArray($request): array
    {
        return [
            'id' => $this->id,
            'name' => $this->full_name,  // Combined name
            'email' => $this->email,
            'created_at' => $this->created_at->toIso8601String(),
        ];
    }
}

// app/Http/Resources/V2/UserResource.php
namespace App\Http\Resources\V2;

class UserResource extends JsonResource
{
    public function toArray($request): array
    {
        return [
            'id' => $this->id,
            'first_name' => $this->first_name,
            'last_name' => $this->last_name,
            'email' => $this->email,
            'profile' => [
                'avatar_url' => $this->avatar_url,
                'bio' => $this->bio,
            ],
            'metadata' => [
                'created_at' => $this->created_at->toIso8601String(),
                'updated_at' => $this->updated_at->toIso8601String(),
            ],
        ];
    }
}

Each resource class is self-contained and testable in isolation.

Version-Specific Controllers

For significant version differences, create separate controllers. This keeps version logic out of your business logic.

When versions differ significantly, use separate controllers. Each controller handles its own request validation and transformation logic.

// app/Http/Controllers/Api/V1/UserController.php
namespace App\Http\Controllers\Api\V1;

class UserController extends Controller
{
    public function store(CreateUserRequest $request): UserResource
    {
        $user = User::create([
            'full_name' => $request->name,  // V1 uses combined name
            'email' => $request->email,
        ]);

        return new UserResource($user);
    }
}

// app/Http/Controllers/Api/V2/UserController.php
namespace App\Http\Controllers\Api\V2;

class UserController extends Controller
{
    public function store(CreateUserRequest $request): UserResource
    {
        $user = User::create([
            'first_name' => $request->first_name,  // V2 uses split names
            'last_name' => $request->last_name,
            'email' => $request->email,
        ]);

        return new UserResource($user);
    }
}

Each version can evolve independently, and the request validation classes can differ between versions too.

Deprecation Strategy

Sunset Headers

The Sunset header is a standard way to communicate when an API version will be retired. Include it in responses from deprecated versions.

This middleware adds standard deprecation headers to responses from old API versions. Clients can parse these headers to track deprecation timelines.

class DeprecatedVersionMiddleware
{
    private array $deprecatedVersions = [
        'v1' => '2025-06-01',
    ];

    public function handle(Request $request, Closure $next)
    {
        $response = $next($request);

        $version = $request->segment(2); // e.g., 'v1'

        if (isset($this->deprecatedVersions[$version])) {
            $sunsetDate = $this->deprecatedVersions[$version];

            $response->headers->set('Sunset', $sunsetDate);
            $response->headers->set('Deprecation', 'true');
            $response->headers->set(
                'Link',
                '</api/v2>; rel="successor-version"'
            );
        }

        return $response;
    }
}

The Link header points clients to the new version, making migration easier to discover.

Deprecation Notices in Response

For clients that might miss headers, include deprecation warnings in the response body. This ensures the message reaches developers reviewing API responses.

Add deprecation information directly in the response body for visibility. Developers reviewing API responses will see the warning and migration guide.

class V1UserResource extends JsonResource
{
    public function toArray($request): array
    {
        return [
            'id' => $this->id,
            'name' => $this->full_name,
            'email' => $this->email,
            '_deprecation' => [
                'message' => 'API v1 is deprecated. Please migrate to v2.',
                'sunset_date' => '2025-06-01',
                'migration_guide' => 'https://docs.example.com/api/migration/v1-to-v2',
            ],
        ];
    }
}

Tracking Deprecated API Usage

You need to know who's still using deprecated versions before you can retire them. Track usage by client to plan your deprecation timeline.

Track which clients are still using deprecated versions. This data helps you reach out to specific clients and plan your retirement timeline.

class DeprecationTracker
{
    public function track(Request $request, string $version): void
    {
        // Log usage for migration planning
        Log::channel('deprecation')->info('Deprecated API called', [
            'version' => $version,
            'endpoint' => $request->path(),
            'client_id' => $request->attributes->get('client_id'),
            'user_agent' => $request->userAgent(),
        ]);

        // Track metrics
        $this->metrics->increment('api.deprecated.calls', [
            'version' => $version,
            'endpoint' => $request->path(),
        ]);
    }
}

These metrics help you reach out to specific clients before deprecation dates and understand usage patterns.

Non-Breaking Changes

Not all changes require versioning. These modifications are generally safe to make within an existing version.

These changes are typically safe to make without creating a new API version. Well-designed clients should handle them gracefully.

// Adding new fields (safe)
{
    "user": {
        "id": 123,
        "name": "John",
        "email": "john@example.com",
        "avatar_url": "https://..."  // New field - clients ignore unknown fields
    }
}

// Adding new endpoints (safe)
Route::get('/api/v1/users/{id}/preferences', [PreferencesController::class, 'show']);

// Adding optional parameters (safe)
// GET /api/v1/users?include=orders  // New optional param

// Adding new enum values (usually safe, but verify client handling)
{
    "status": "pending_review"  // New status value
}

Well-designed clients ignore unknown fields, making additive changes safe without versioning.

Breaking Changes Requiring New Version

These changes will break existing clients and require a new API version.

Any of these changes will break existing clients. Always create a new API version when making these modifications.

// Removing fields
// Renaming fields
// Changing field types
// Changing response structure
// Removing endpoints
// Changing authentication
// Changing error formats

When in doubt, consider it a breaking change. It's better to be conservative than to break production clients.

Migration Guides

Documentation Template

When introducing a new version, provide clear migration documentation. This template covers the essentials.

Provide comprehensive migration documentation for each version change. This template gives clients everything they need to upgrade successfully.

# API Migration Guide: V1 to V2

## Overview
API V2 introduces improved user data structure and enhanced error responses.

## Breaking Changes

### User Object Changes

| V1 Field | V2 Field | Notes |
|----------|----------|-------|
| `name` | `first_name`, `last_name` | Split into separate fields |
| `created_at` | `metadata.created_at` | Moved to metadata object |

### Before (V1)
```json
{
    "user": {
        "name": "John Doe",
        "created_at": "2024-01-15T10:30:00Z"
    }
}

After (V2)

{
    "user": {
        "first_name": "John",
        "last_name": "Doe",
        "metadata": {
            "created_at": "2024-01-15T10:30:00Z"
        }
    }
}

Migration Steps

  1. Update your user parsing logic to handle split names
  2. Update created_at access path
  3. Test with V2 endpoints
  4. Switch API version header/URL

Good documentation reduces support burden and accelerates client migrations.

## SDK Versioning

If you provide client SDKs, version them alongside your API. This gives clients compile-time safety when upgrading.

Versioned SDKs provide type safety for clients. They can catch breaking changes at compile time rather than discovering them in production.

```php
// Provide versioned SDKs
class MyApiClient
{
    public static function v1(string $apiKey): V1\Client
    {
        return new V1\Client($apiKey);
    }

    public static function v2(string $apiKey): V2\Client
    {
        return new V2\Client($apiKey);
    }
}

// Usage
$client = MyApiClient::v2($apiKey);
$user = $client->users()->get(123);
echo $user->firstName; // V2 structure

Type hints in the SDK catch breaking changes at compile time rather than runtime.

Testing Multiple Versions

Use parameterized tests to verify all supported versions work correctly. This catches regressions across versions.

Data providers let you test multiple API versions with the same test logic. This ensures all versions continue working as expected.

class UserApiTest extends TestCase
{
    /** @dataProvider versionProvider */
    public function test_get_user_returns_correct_structure(string $version, array $expectedFields)
    {
        $user = User::factory()->create();

        $response = $this->getJson("/api/{$version}/users/{$user->id}");

        $response->assertStatus(200);

        foreach ($expectedFields as $field) {
            $response->assertJsonStructure(['data' => [$field]]);
        }
    }

    public static function versionProvider(): array
    {
        return [
            'v1' => ['v1', ['id', 'name', 'email']],
            'v2' => ['v2', ['id', 'first_name', 'last_name', 'email', 'metadata']],
        ];
    }
}

This pattern scales well as you add more versions and endpoints.

Best Practices

  1. Version from day one - Easier than adding later
  2. Use semantic versioning - Major.minor.patch
  3. Document everything - Changelog, migration guides
  4. Provide long deprecation periods - 6-12 months minimum
  5. Track usage - Know who uses deprecated versions
  6. Support at least N-1 - Current and previous major version
  7. Communicate proactively - Email clients about deprecations

These practices build trust with your API consumers and make versioning manageable at scale.

Conclusion

API versioning is essential for evolving your API without breaking existing clients. URL path versioning is the most practical approach for major versions. Deprecate gracefully with sunset headers, migration guides, and long notice periods. Track deprecated version usage to plan retirement. The goal is enabling progress while respecting the investment clients have made integrating with your API.

Share this article

Related Articles

Distributed Locking Patterns

Coordinate access to shared resources across services. Implement distributed locks with Redis, ZooKeeper, and databases.

Jan 16, 2026

Need help with your project?

Let's discuss how we can help you build reliable software.