Practical API versioning strategies covering URL-based, header-based, and evolution-based approaches with migration patterns.
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.
Before choosing a versioning strategy, you need a clear definition of what constitutes a breaking change.
Breaking changes (always require a new version):
Non-breaking changes (safe without versioning):
The rule: You can always add, never remove, never change.
GET /api/v1/invoices
GET /api/v2/invoices
Pros:
Cons:
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.
GET /api/invoices
Accept: application/vnd.myapp.v2+json
Or using a custom header:
GET /api/invoices
X-API-Version: 2
Pros:
Cons:
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();
}
}
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.
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.
Combine URL versioning for major structural changes with API evolution for minor changes:
v1v2 only when a fundamental restructure is needed (new resource model, authentication change, pagination change)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.
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);
}
}
When retiring an API version:
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.
Whether you're modernizing your infrastructure, navigating compliance, or building new software - we can help.
Book a 30-min Call