Most teams skip API versioning on day one and spend months paying for it later. Here’s the complete guide — URL versioning vs header versioning vs content negotiation, with a Laravel implementation that won’t make your team hate you.
There’s a moment in every growing API project when someone asks: “Can we change the response format for /api/users?” And then there’s the moment of silence that follows when everyone in the room realises you can’t — because three mobile apps, two third-party integrations, and a partner portal all depend on the current format, and none of them can be updated simultaneously.
API versioning is the mechanism that prevents that silence. It gives you the ability to evolve your API without breaking existing consumers. It’s one of the earliest architectural decisions you’ll make, one of the least reversible, and one of the most frequently skipped because it feels premature on day one.
It never feels premature on day 90.
Why API Versioning Matters: The Real Scenarios
Before the implementation, a clear picture of when versioning actually matters — because some teams genuinely don’t need it, and adding it unnecessarily is also a mistake.
You need API versioning if:
- Your API is consumed by mobile apps you don’t control (iOS/Android apps can’t force users to update)
- Your API is consumed by third-party developers or partner integrations
- Different teams own different consumers and can’t always coordinate releases
- You’re running a public API where breaking changes would violate your contract with users
You probably don’t need API versioning if:
- You own and control every consumer (single SPA, no third-party integrations)
- All consumers can be updated simultaneously with the API
- You’re in early development and can afford to break changes while you figure out the data model
If you’re in the second category, versioning may be overhead you don’t need yet. The key word is “yet” — because it’s much easier to add versioning before you have consumers than after.
The Three Versioning Strategies
Strategy 1: URL Versioning
The version is part of the URL path.
GET /api/v1/users
GET /api/v2/users
Pros:
- Immediately obvious — the version is visible in every URL, log entry, and error report
- Easy to test in a browser or Postman without special headers
- Easy to route in Laravel — standard route groups
- Easy to document — API docs can clearly separate v1 from v2
- Easy to deprecate — you can see which version is being called in access logs
Cons:
- URLs are supposed to represent resources, not versions — philosophically “wrong” by REST purists
- Duplicates URL structure across versions
- Cache invalidation is simpler (different URLs = different cache keys) — this is actually a pro
Verdict: The pragmatic choice for most teams. Every major API that you interact with daily uses URL versioning — Stripe, Twilio, GitHub. The “philosophically impure” argument is real but rarely relevant in practice.
Strategy 2: Header Versioning
The version is sent as a custom HTTP header.
GET /api/users
Accept-Version: 2
or
GET /api/users
X-API-Version: 2
Pros:
- Clean URLs — the resource path stays stable
- Closer to REST purity
- Easy to set globally in an HTTP client
Cons:
- Not visible in URLs — harder to debug, harder to reproduce issues, harder to read access logs
- Cannot be tested in a browser address bar
- Easy to forget — consumers must explicitly send the header
- The default behaviour (what version do you get if you send no header?) is ambiguous and requires a decision
- Cache keys in CDNs and proxies are URL-based by default — you need
Vary: Accept-Versionheaders to cache correctly, which is often overlooked
Verdict: Clean but operationally painful. Teams that choose header versioning frequently discover the debugging overhead when something goes wrong in production and the log entry just says GET /api/users with no indication of which version the consumer was calling.
Strategy 3: Content Negotiation (Accept Header)
The version is embedded in the Accept header using MIME type versioning.
GET /api/users
Accept: application/vnd.myapp.v2+json
Pros:
- Technically the most REST-compliant approach
- Versioning is tied to the representation, not the resource
Cons:
- Verbose and unfamiliar to most API consumers
- Requires custom MIME type parsing in middleware
- Even harder to debug than header versioning
- Very few teams implement it correctly
- Almost no tooling has first-class support for it
Verdict: Academically interesting, practically painful. Unless you’re building a hypermedia API for a specifically REST-compliant reason, this adds complexity without proportional benefit.
The Honest Recommendation
Use URL versioning. Here’s why, stated plainly:
- Every developer on your team will understand it immediately
- Every log entry will include the version — debugging is trivial
- Every HTTP client and tooling layer handles it without special configuration
- Stripe uses it. GitHub uses it. Twilio uses it. The argument that it’s wrong has been settled by real-world adoption.
The REST purists are not wrong that URLs should represent resources, not versions. But the operational benefits of URL versioning over header versioning are concrete and significant, and the philosophical purity of the alternative doesn’t ship features or fix bugs.
Implementing URL Versioning in Laravel
Directory Structure
app/
├── Http/
│ ├── Controllers/
│ │ ├── Api/
│ │ │ ├── V1/
│ │ │ │ ├── UserController.php
│ │ │ │ ├── OrderController.php
│ │ │ │ └── ProductController.php
│ │ │ └── V2/
│ │ │ ├── UserController.php ← only if V2 changes this resource
│ │ │ └── OrderController.php ← only if V2 changes this resource
│ └── Resources/
│ ├── V1/
│ │ ├── UserResource.php
│ │ └── OrderResource.php
│ └── V2/
│ └── UserResource.php ← only if V2 changes the representation
The key principle: only duplicate what changes. If the V2 orders endpoint is identical to V1, don’t create a V2 OrderController — just point the V2 route to the V1 controller. The version namespace exists to accommodate differences, not to duplicate everything.
Route Configuration
// routes/api.php
use App\Http\Controllers\Api\V1;
use App\Http\Controllers\Api\V2;
// V1 routes
Route::prefix('v1')
->name('api.v1.')
->middleware(['api', 'auth:sanctum'])
->group(function () {
Route::apiResource('users', V1\UserController::class)
Route::apiResource('orders', V1\OrderController::class)
Route::apiResource('products', V1\ProductController::class)
});
// V2 routes
Route::prefix('v2')
->name('api.v2.')
->middleware(['api', 'auth:sanctum'])
->group(function () {
// Changed in V2 — use V2 controller
Route::apiResource('users', V2\UserController::class)
// Unchanged in V2 — reuse V1 controller
Route::apiResource('orders', V1\OrderController::class)
Route::apiResource('products', V1\ProductController::class)
});
V1 User Controller
// app/Http/Controllers/Api/V1/UserController.php
<?php
namespace App\Http\Controllers\Api\V1;
use App\Http\Controllers\Controller;
use App\Http\Resources\V1\UserResource;
use App\Models\User;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\AnonymousResourceCollection;
class UserController extends Controller
{
public function index(): AnonymousResourceCollection
{
$users = User::with('address')->paginate(20);
return UserResource::collection($users);
}
public function show(User $user): UserResource
{
return new UserResource($user->load('address'));
}
public function store(Request $request): UserResource
{
$validated = $request->validate([
'name' => ['required', 'string', 'max:255'],
'email' => ['required', 'email', 'unique:users'],
'password' => ['required', 'string', 'min:8'],
]);
$user = User::create($validated);
return new UserResource($user);
}
public function update(Request $request, User $user): UserResource
{
$validated = $request->validate([
'name' => ['sometimes', 'string', 'max:255'],
'email' => ['sometimes', 'email', 'unique:users,email,' . $user->id],
]);
$user->update($validated);
return new UserResource($user);
}
public function destroy(User $user): \Illuminate\Http\Response
{
$user->delete();
return response()->noContent();
}
}
V1 User Resource
// app/Http/Resources/V1/UserResource.php
<?php
namespace App\Http\Resources\V1;
use Illuminate\Http\Resources\Json\JsonResource;
class UserResource extends JsonResource
{
public function toArray($request): array
{
return [
'id' => $this->id,
'name' => $this->name,
'email' => $this->email,
'address' => $this->whenLoaded('address', fn() => [
'street' => $this->address->street,
'city' => $this->address->city,
'country' => $this->address->country,
]),
'created_at' => $this->created_at->toISOString(),
];
}
}
V2 User Resource — What Changed
In V2, imagine we restructured the address field, added a phone field, and changed the name field into first_name + last_name.
// app/Http/Resources/V2/UserResource.php
<?php
namespace App\Http\Resources\V2;
use Illuminate\Http\Resources\Json\JsonResource;
class UserResource extends JsonResource
{
public function toArray($request): array
{
return [
'id' => $this->id,
// V2 change: name split into first_name + last_name
'first_name' => $this->first_name,
'last_name' => $this->last_name,
'email' => $this->email,
// V2 addition: phone field
'phone' => $this->phone,
// V2 change: address is now a nested object with a type field
'address' => $this->whenLoaded('address', fn() => [
'type' => $this->address->type, // 'home' | 'work' | 'billing'
'line1' => $this->address->line1,
'line2' => $this->address->line2,
'city' => $this->address->city,
'postal_code' => $this->address->postal_code,
'country' => $this->address->country,
]),
'created_at' => $this->created_at->toISOString(),
'updated_at' => $this->updated_at->toISOString(), // V2 addition
];
}
}
// app/Http/Controllers/Api/V2/UserController.php
// Only override what actually changed
namespace App\Http\Controllers\Api\V2;
use App\Http\Controllers\Controller;
use App\Http\Resources\V2\UserResource;
use App\Models\User;
use Illuminate\Http\Request;
class UserController extends Controller
{
public function index()
{
$users = User::with('address')->paginate(20);
return UserResource::collection($users); // uses V2 resource
}
public function show(User $user): UserResource
{
return new UserResource($user->load('address'));
}
public function store(Request $request): UserResource
{
$validated = $request->validate([
'first_name' => ['required', 'string', 'max:255'], // V2: split name
'last_name' => ['required', 'string', 'max:255'],
'email' => ['required', 'email', 'unique:users'],
'phone' => ['nullable', 'string'], // V2: added phone
'password' => ['required', 'string', 'min:8'],
]);
$user = User::create($validated);
return new UserResource($user);
}
public function update(Request $request, User $user): UserResource
{
$validated = $request->validate([
'first_name' => ['sometimes', 'string', 'max:255'],
'last_name' => ['sometimes', 'string', 'max:255'],
'email' => ['sometimes', 'email', 'unique:users,email,' . $user->id],
'phone' => ['sometimes', 'nullable', 'string'],
]);
$user->update($validated);
return new UserResource($user);
}
public function destroy(User $user): \Illuminate\Http\Response
{
$user->delete();
return response()->noContent();
}
}
Version Detection Middleware
Adding a middleware that detects and stores the current API version makes it available throughout the request — useful for logging, conditional logic in shared services, and rate limiting.
// app/Http/Middleware/SetApiVersion.php
<?php
namespace App\Http\Middleware;
use Closure;
use Illuminate\Http\Request;
class SetApiVersion
{
public function handle(Request $request, Closure $next, string $version = 'v1')
{
// Store version on the request for use downstream
$request->attributes->set('api_version', $version);;
// Set response header so clients know which version served them
$response = $next($request);
$response->headers->set('X-API-Version', $version);
return $response;
}
}
// routes/api.php — pass version as middleware parameter
Route::prefix('v1')
->middleware(['api', 'auth:sanctum', 'api.version:v1'])
->group(function () { ... });
Route::prefix('v2')
->middleware(['api', 'auth:sanctum', 'api.version:v2'])
->group(function () { ... });
// bootstrap/app.php — register the middleware alias
->withMiddleware(function (Middleware $middleware) {
$middleware->alias([
'api.version' => \App\Http\Middleware\SetApiVersion::class,
]);
})
Access the version anywhere downstream:
// In a controller, service, or anywhere with request access
$version = request()->attributes->get('api_version'); // 'v1' or 'v2'
Handling Deprecation
Versioning without deprecation is incomplete. When you ship V2, you need a plan for V1 — how long it lives, how you communicate its end-of-life, and how you enforce the sunset.
Sunset Headers
RFC 8594 defines the Sunset HTTP header for communicating endpoint deprecation. Adding this to V1 responses tells consumers they need to migrate:
// app/Http/Middleware/DeprecateV1.php
<?php
namespace App\Http\Middleware;
use Closure;
use Illuminate\Http\Request;
class DeprecateV1
{
// V1 sunset date — 6 months from V2 launch
private const SUNSET_DATE = 'Sun, 01 Dec 2026 00:00:00 GMT';
private const MIGRATION_DOCS = 'https://docs.yourapp.com/api/migration/v1-to-v2';
public function handle(Request $request, Closure $next)
{
$response = $next($request);
$response->headers->set('Sunset', self::SUNSET_DATE);
$response->headers->set('Deprecation', 'true');
$response->headers->set('Link',
'<' . self::MIGRATION_DOCS . '>; rel="deprecation"'
);
return $response;
}
}
// Apply to V1 routes only
Route::prefix('v1')
->middleware(['api', 'auth:sanctum', 'api.version:v1', 'deprecate.v1'])
->group(function () { ... });
Logging Deprecated Version Usage
// app/Http/Middleware/DeprecateV1.php — extended with logging
public function handle(Request $request, Closure $next)
{
// Log every V1 request so you can measure migration progress
Log::info('Deprecated V1 API accessed', [
'path' => $request->path(),
'method' => $request->method(),
'user_id' => auth()->id(),
'user_agent' => $request->userAgent(),
'ip' => $request->ip(),
]);
$response = $next($request);
$response->headers->set('Sunset', self::SUNSET_DATE);
$response->headers->set('Deprecation', 'true');
$response->headers->set('Link', '<' . self::MIGRATION_DOCS . '>; rel="deprecation"');
return $response;
}
Once V1 usage drops to zero in your logs, you can safely remove it.
Hard Sunset: Returning 410 Gone
After the sunset date, return 410 Gone instead of serving the response. This forces any consumers that ignored the deprecation notices to finally update.
// app/Http/Middleware/EnforceSunset.php
public function handle(Request $request, Closure $next)
{
if (now()->isAfter(Carbon::parse('2026-12-01'))) {
return response()->json([
'error' => 'API version deprecated',
'message' => 'V1 of this API was sunset on December 1, 2026. Please migrate to V2.',
'docs' => 'https://docs.yourapp.com/api/migration/v1-to-v2',
], 410);
}
return $next($request);
}
Testing Versioned APIs
Each version should have its own test suite that verifies the contract independently. If V2 changes the response format, V1 tests should still pass with the old format.
// tests/Feature/Api/V1/UserControllerTest.php
class V1UserControllerTest extends TestCase
{
use RefreshDatabase;
public function test_v1_returns_name_as_single_field(): void
{
$user = User::factory()->create([
'first_name' => 'Taylor',
'last_name' => 'Otwell',
]);
$this->actingAs($user)
->getJson('/api/v1/users/' . $user->id)
->assertOk()
->assertJsonStructure([
'data' => ['id', 'name', 'email', 'created_at'],
])
->assertJsonPath('data.name', 'Taylor Otwell')
// V1 should NOT have first_name/last_name fields
->assertJsonMissingPath('data.first_name')
->assertJsonMissingPath('data.last_name');
}
}
// tests/Feature/Api/V2/UserControllerTest.php
class V2UserControllerTest extends TestCase
{
use RefreshDatabase;
public function test_v2_returns_name_as_split_fields(): void
{
$user = User::factory()->create([
'first_name' => 'Taylor',
'last_name' => 'Otwell',
]);
$this->actingAs($user)
->getJson('/api/v2/users/' . $user->id)
->assertOk()
->assertJsonStructure([
'data' => ['id', 'first_name', 'last_name', 'email', 'phone', 'created_at', 'updated_at'],
])
->assertJsonPath('data.first_name', 'Taylor')
->assertJsonPath('data.last_name', 'Otwell')
// V2 should NOT have the combined name field
->assertJsonMissingPath('data.name');
}
public function test_v2_includes_phone_field(): void
{
$user = User::factory()->create(['phone' => '+91 98765 43210']);
$this->actingAs($user)
->getJson('/api/v2/users/' . $user->id)
->assertOk()
->assertJsonPath('data.phone', '+91 98765 43210');
}
}
Common Mistakes and How to Avoid Them
Mistake 1: Versioning Too Granularly
Some teams create a new version for every minor change. V1, V2, V3, V4 within six months. This is worse than no versioning — consumers can’t keep up and the codebase becomes unmaintainable.
✓ Version when there are BREAKING changes:
- Removing a field from a response
- Renaming a field
- Changing a field's type
- Changing authentication requirements
- Changing required vs optional parameters
✓ Do NOT version for:
- Adding a new optional field (backwards compatible)
- Adding a new endpoint (backwards compatible)
- Bug fixes that correct incorrect behaviour
- Performance improvements
- Internal refactors
Mistake 2: Copying Entire Controllers for Minor Changes
// ✗ Copying UserController entirely when only the response format changed
// V2/UserController.php is 200 lines, 190 of which are identical to V1
// ✓ Keep shared logic in the V1 controller, override only what changed
// V2/UserController.php extends V1 or only overrides the relevant methods
class V2UserController extends V1UserController
{
// Only the methods that actually changed in V2
public function index()
{
$users = User::with('address')->paginate(20);
return V2UserResource::collection($users); // just the resource changed
}
public function show(User $user): V2UserResource
{
return new V2UserResource($user->load('address'));
}
}
Mistake 3: No Default Version
When a consumer calls /api/users without a version prefix, what happens? If you return a 404, you’ve broken any existing client. If you silently serve V1, you’ve created implicit behaviour that’s hard to document. Define a default and document it explicitly.
// routes/api.php — redirect unversioned requests to the current stable version
Route::prefix('')->group(function () {
// Redirect /api/users to /api/v1/users (or v2 when v1 is deprecated)
Route::any('{any}', function ($any) {
return redirect('/api/v1/' . $any, 301);
})->where('any', '.*');
});
Or reject them explicitly:
Route::fallback(function () {
return response()->json([
'error' => 'No API version specified',
'message' => 'Please use a versioned endpoint: /api/v1/ or /api/v2/',
'docs' => 'https://docs.yourapp.com/api',
], 400);
});
Mistake 4: Forgetting to Version the Documentation
API versioning without versioned documentation is nearly useless. Each version should have its own documentation that clearly indicates which version it applies to, when it was introduced, and (when relevant) when it will be deprecated.
The Versioning Checklist
Before shipping your versioned API:
✓ URL prefix chosen (/api/v1/ or /api/v{n}/)
✓ Route groups configured with version prefix and name
✓ Controllers and Resources in version-namespaced directories
✓ Only changed endpoints are duplicated — unchanged routes point to V1
✓ SetApiVersion middleware adds X-API-Version to every response
✓ Default version behaviour defined and documented
✓ DeprecateV1 middleware with Sunset + Deprecation headers on old versions
✓ V1 usage logging to track migration progress
✓ Sunset enforcement date planned and documented
✓ Feature tests for each version verifying the contract independently
✓ Tests assert that V1 fields are absent from V2 and vice versa
✓ API documentation versioned to match the code
✓ Migration guide written before V1 deprecation headers go live
Final Thoughts
The silence in the room when someone asks “can we change this API response?” is entirely preventable. Versioning is not a complex feature — it’s a directory structure, a route prefix, and the discipline to keep version boundaries clean. The implementation is an afternoon of work. The benefit is months of flexibility.
The decision matters most on day one. Once consumers exist and depend on a specific format, the ability to change anything without a versioning strategy ranges from very difficult to practically impossible. Adding versioning to an existing, consumed API is significantly harder than adding it before the first consumer ships.
URL versioning is the pragmatic choice. Pick it. Add the directory structure. Define the default behaviour. Write the deprecation middleware now even if you don’t plan to deprecate anything yet. That middleware is your future self’s best friend.
Your API is a contract. Version it before the other party signs it.
