Software Teams

API Versioning Strategies: Maintaining Backwards Compatibility

Practical API versioning strategies covering URL-based, header-based, and evolution-based approaches with migration patterns.

You Will Need to Change Your API

Every API that lives long enough will need breaking changes. New business requirements demand new fields, changed workflows, or restructured resources. The question is not whether to version your API but how to do it without breaking existing consumers.

Getting this wrong is expensive. A breaking change that affects 200 API consumers triggers 200 support tickets, erodes trust, and may violate contractual SLAs. A thoughtful versioning strategy prevents this entirely.

What Counts as a Breaking Change?

Before choosing a versioning strategy, you need a clear definition of what constitutes a breaking change.

Breaking changes (always require a new version):

  • Removing a field from a response
  • Renaming a field
  • Changing a field's data type (string to integer)
  • Removing an endpoint
  • Changing required parameters
  • Changing error response structure
  • Changing authentication mechanisms
  • Altering the meaning of a field (units, semantics)

Non-breaking changes (safe without versioning):

  • Adding new fields to a response
  • Adding new optional query parameters
  • Adding new endpoints
  • Adding new enum values (if consumers handle unknown values gracefully)
  • Performance improvements
  • Bug fixes that bring behavior in line with documentation

The rule: You can always add, never remove, never change.

Versioning Strategies

URL Path Versioning

GET /api/v1/invoices
GET /api/v2/invoices

Pros:

  • Obvious and explicit. The version is visible in every request.
  • Easy to route in web frameworks.
  • Easy to document separately.
  • Simple for consumers to understand.

Cons:

  • Pollutes the URL space. URLs should identify resources, not API versions.
  • Makes incremental changes difficult since you need an entire new version for one changed endpoint.
  • Consumers must update every URL when migrating to a new version.

Implementation in Laravel:

Route::prefix('api/v1')->group(function () {
    Route::apiResource('invoices', V1\InvoiceController::class);
});

Route::prefix('api/v2')->group(function () {
    Route::apiResource('invoices', V2\InvoiceController::class);
});

This is the most common approach and works well for APIs with clear major version boundaries.

Header-Based Versioning

GET /api/invoices
Accept: application/vnd.myapp.v2+json

Or using a custom header:

GET /api/invoices
X-API-Version: 2

Pros:

  • Clean URLs that identify resources only.
  • Version selection is a cross-cutting concern handled in middleware.
  • Easy to set a default version for consumers who do not specify one.

Cons:

  • Less visible. You cannot tell the version from the URL alone.
  • Harder to test in a browser or with simple tools like curl.
  • Some proxies and caches may not handle custom headers correctly.

Implementation in Laravel:

// Middleware
class ApiVersionMiddleware
{
    public function handle(Request $request, Closure $next): Response
    {
        $version = $request->header('X-API-Version', '1');
        $request->attributes->set('api_version', (int) $version);

        return $next($request);
    }
}

// Controller
class InvoiceController
{
    public function index(Request $request): JsonResponse
    {
        $invoices = Invoice::paginate();

        $resourceClass = match ($request->attributes->get('api_version')) {
            2 => InvoiceResourceV2::class,
            default => InvoiceResourceV1::class,
        };

        return $resourceClass::collection($invoices)->response();
    }
}

Query Parameter Versioning

GET /api/invoices?version=2

Pros: Simple, no special headers needed. Cons: Mixes versioning concerns with query parameters. Easy to forget. Not recommended for public APIs.

API Evolution (No Explicit Versioning)

Instead of versioning, evolve the API by making only additive changes and using feature negotiation:

{
  "data": {
    "id": 42,
    "invoice_number": "INV-2026-042",
    "total": "1250.00",
    "total_in_cents": 125000
  }
}

When you need to change a field's format (e.g., money representation), add the new field alongside the old one. Deprecate the old field with documentation and sunset headers:

Sunset: Sat, 01 Jul 2026 00:00:00 GMT
Deprecation: true
Link: <https://api.example.com/docs/migration>; rel="deprecation"

Pros: No version proliferation. Consumers migrate field by field at their own pace. Cons: Accumulates deprecated fields over time. Requires disciplined consumers who read deprecation notices.

The Practical Approach: Evolutionary Versions

Combine URL versioning for major structural changes with API evolution for minor changes:

  • Start at v1
  • Add fields, endpoints, and optional parameters without version bumps
  • Deprecate fields with sunset headers and a migration guide
  • Bump to v2 only when a fundamental restructure is needed (new resource model, authentication change, pagination change)
  • Support the previous version for a defined period (typically 12-24 months for public APIs)

Managing Multiple Versions

Code Organization

Separate transformers, shared logic:

app/
  Http/
    Resources/
      V1/InvoiceResource.php
      V2/InvoiceResource.php
    Controllers/
      V1/InvoiceController.php
      V2/InvoiceController.php
  Domain/
    Billing/
      Actions/CreateInvoiceAction.php    # Shared between versions
      Models/Invoice.php                  # Shared between versions

Business logic stays version-agnostic. Only the HTTP layer (controllers, resources, requests) is versioned. This prevents duplicating domain logic across versions.

Testing Multiple Versions

Test every supported version in your CI pipeline:

class InvoiceApiV1Test extends TestCase
{
    public function test_v1_returns_total_as_string(): void
    {
        $invoice = Invoice::factory()->create(['total' => 12500]);

        $this->getJson('/api/v1/invoices/' . $invoice->id)
            ->assertJsonPath('data.total', '125.00');
    }
}

class InvoiceApiV2Test extends TestCase
{
    public function test_v2_returns_total_in_cents(): void
    {
        $invoice = Invoice::factory()->create(['total' => 12500]);

        $this->getJson('/api/v2/invoices/' . $invoice->id)
            ->assertJsonPath('data.total_in_cents', 12500);
    }
}

Deprecation and Sunset Process

When retiring an API version:

  1. Announce the deprecation at least 6 months before the sunset date. Notify consumers via email, documentation banners, and response headers.
  2. Add deprecation headers to all responses from the deprecated version.
  3. Monitor usage of the deprecated version. Contact high-volume consumers directly to ensure they are aware.
  4. Provide a migration guide documenting every difference between the old and new versions with code examples.
  5. Sunset gracefully. On the sunset date, return 410 Gone instead of silently breaking.
// After sunset
Route::prefix('api/v1')->group(function () {
    Route::any('{any}', function () {
        return response()->json([
            'error' => 'API v1 has been retired. Please migrate to v2.',
            'documentation' => 'https://api.example.com/docs/v2/migration',
        ], 410);
    })->where('any', '.*');
});

The goal is to make versioning invisible to consumers who stay current and painless for those who need to migrate. Versioning is a promise: we will not break your integration without giving you a clear path forward.

Let's talk about your software teams needs

Whether you're modernizing your infrastructure, navigating compliance, or building new software - we can help.

Book a 30-min Call