Laravel API versioning is a decision you make once and live with for years, because the first time you rename a field or change a response shape, every client you don't control breaks at the same moment. Part 1 covered routing basics, Part 2 model binding, Part 3 middleware, and Part 4 route caching. This part covers how to run v1 and v2 side by side, how to retire the old one, and how to keep a route list that has grown to hundreds of entries readable.
First, what actually needs a new version
Most API changes don't. A new endpoint, a new optional request field, or an extra field in a response are additive: existing clients ignore what they don't know about. Create a new version only for breaking changes:
- Removing or renaming a response field
- Changing a field's type (a string ID becoming an integer, a flat value becoming an object)
- Making a previously optional request field required
- Changing status codes or the error response format clients parse
If every small tweak becomes a version, you end up maintaining five copies of the API. Keep versions for real breaks.
Strategy 1: the version in the URL
URI versioning puts the version in the path: /api/v1/users. It is the most common choice because it's visible in logs, easy to test in a browser, easy to cache, and trivial to route. Unless you have a strong reason otherwise, start here.
The quick option: apiPrefix
In Laravel 11 and later, apiPrefix in bootstrap/app.php changes the prefix applied to every route in api.php:
// bootstrap/app.php
->withRouting(
web: __DIR__.'/../routes/web.php',
api: __DIR__.'/../routes/api.php',
apiPrefix: 'api/v1',
)
This works while you have exactly one version. The moment v2 exists it stops being enough, because the prefix is global to the whole file.
The scalable option: one file per version
Keep apiPrefix at its default and load a separate route file per version from inside api.php. That file already runs under the /api prefix and the api middleware group, so you only add the version:
// routes/api.php
use Illuminate\Support\Facades\Route;
Route::prefix('v1')->name('v1.')->group(base_path('routes/api_v1.php'));
Route::prefix('v2')->name('v2.')->group(base_path('routes/api_v2.php'));
Note the name('v1.') and name('v2.') prefixes. Without them, both versions would register a route called users.show, and php artisan route:cache would fail with the duplicate-name error from Part 4. The name prefix is what lets both versions exist at once.
Each file then looks like any other route file:
// routes/api_v1.php
use App\Http\Controllers\Api\V1;
Route::get('/users/{user}', [V1\UserController::class, 'show'])->name('users.show');
// URL: /api/v1/users/5 name: v1.users.show
// routes/api_v2.php
use App\Http\Controllers\Api\V2;
// v2 looks users up by uuid instead of id (see Part 2)
Route::get('/users/{user:uuid}', [V2\UserController::class, 'show'])->name('users.show');
// URL: /api/v2/users/9b2f... name: v2.users.show
Route model binding is shared across versions, so a version that changes how a record is looked up should say so on its own routes, as {user:uuid} does here, rather than changing the model's getRouteKeyName() and breaking v1.
Version the response, not the whole application
The thing that really changes between versions is the shape of the response, not your models or business logic. Keep one model and one service layer, and version only the transformation. Laravel's API resources are the natural place:
// app/Http/Resources/V1/UserResource.php
class UserResource extends JsonResource
{
public function toArray(Request $request): array
{
return [
'id' => $this->id,
'name' => $this->name,
];
}
}
// app/Http/Resources/V2/UserResource.php
class UserResource extends JsonResource
{
public function toArray(Request $request): array
{
return [
'id' => $this->uuid,
'first_name' => $this->first_name,
'last_name' => $this->last_name,
];
}
}
Each version's controller returns its own resource and calls the same underlying action or service. That keeps a bug fix in business logic applied to every version at once, while the contract each client sees stays frozen.
The alternative: extra files in bootstrap/app.php
You can also register version files from the then closure of withRouting(), which Laravel's own routing docs use for files like a webhooks route file that needs different middleware:
->withRouting(
web: __DIR__.'/../routes/web.php',
api: __DIR__.'/../routes/api.php',
then: function () {
Route::middleware('api')
->prefix('api/v2')
->name('v2.')
->group(base_path('routes/api_v2.php'));
},
)
Both approaches work. Loading versions from api.php keeps every versioning decision in one routing file, which is easier to review, so prefer it unless a file genuinely needs a different middleware stack.
Strategy 2: the version in a header
Header versioning keeps URLs clean: the client sends Accept: application/vnd.myapp.v2+json to the same /api/users path. The trade-offs are real: it's harder to test from a browser, easy for a client to forget, and caches must be told that the response varies by header.
A small middleware can read the version and set a Vary header so caches store each version separately:
<?php
namespace App\Http\Middleware;
use Closure;
use Illuminate\Http\Request;
use Symfony\Component\HttpFoundation\Response;
class ResolveApiVersion
{
public function handle(Request $request, Closure $next): Response
{
$version = 1;
if (preg_match('#application/vnd\.myapp\.v(\d+)\+json#', $request->header('Accept', ''), $m)) {
$version = (int) $m[1];
}
$request->attributes->set('api_version', $version);
$response = $next($request);
$response->headers->set('Vary', 'Accept', false);
return $response;
}
}
// In a controller
$resource = $request->attributes->get('api_version') === 2
? \App\Http\Resources\V2\UserResource::class
: \App\Http\Resources\V1\UserResource::class;
return new $resource($user);
One pattern to avoid: some example projects choose which route file to load by reading the header while the routes are being registered. Do not do that. As Part 4 explained, route:cache freezes the route table at build time, so a decision based on a request header is baked in once and then ignored. Register all versions' routes unconditionally and choose between them per request, as above.
For most teams, URI versioning is the safer default. Choose headers only if clean URLs are a firm requirement and your clients are disciplined.
Retiring a version: tell clients before you break them
Running v1 forever is not the goal. The HTTP Sunset header (RFC 8594) announces the date a resource will stop working, and a Link header with rel="sunset" can point to the migration guide. Add both to a whole version with a small middleware:
<?php
namespace App\Http\Middleware;
use Closure;
use Illuminate\Http\Request;
use Illuminate\Support\Carbon;
use Symfony\Component\HttpFoundation\Response;
class AddSunsetHeader
{
public function handle(Request $request, Closure $next, string $date, string $guide): Response
{
$response = $next($request);
$response->headers->set('Sunset', Carbon::parse($date)->toRfc7231String());
$response->headers->set('Link', '<'.$guide.'>; rel="sunset"', false);
return $response;
}
}
// routes/api.php
Route::prefix('v1')
->name('v1.')
->middleware(AddSunsetHeader::class.':2027-06-30,https://example.com/docs/migrate-to-v2')
->group(base_path('routes/api_v1.php'));
Before you actually remove a version, check that nobody is still calling it. Log the version on every API request, and only delete the route file once the numbers for the old version have dropped to zero or to clients you have contacted.
Keeping hundreds of routes readable
A single api_v1.php with 400 lines is where routing becomes painful. Group by feature, not by HTTP verb, and give each feature its own file:
routes/
web.php
api.php
api/
v1/
users.php
billing.php
reports.php
v2/
users.php
billing.php
// routes/api.php
Route::prefix('v1')->name('v1.')->group(function () {
require base_path('routes/api/v1/users.php');
require base_path('routes/api/v1/billing.php');
require base_path('routes/api/v1/reports.php');
});
Listing the files explicitly, rather than looping over a folder, makes it obvious in code review exactly what is loaded. It also works fine with route caching, because the cache is built from whatever the route files do at build time.
Subdomain routing for tenants and separate API hosts
When a feature belongs to a subdomain, such as acme.example.com for the account named Acme, use Route::domain(). The subdomain part is captured as a route parameter:
Route::domain('{account}.example.com')->group(function () {
Route::get('/users/{id}', function (string $account, string $id) {
// Domain parameters come first, then path parameters.
});
});
Laravel's docs give one rule that causes subtle bugs when ignored: register subdomain routes before root-domain routes. If a root-domain route with the same path is registered first, it can shadow the subdomain route. Put the Route::domain() group at the top of the file that holds both.
A fixed API host such as api.example.com works the same way, and if the API owns the whole domain you can set apiPrefix: '' to drop the /api segment from the path.
Verifying what you built
route:list can filter by path and by name, which makes it easy to confirm both versions registered and that their names don't collide:
php artisan route:list --path=api/v1
php artisan route:list --name=v2.
php artisan route:cache # fails here if two routes share a name
Quick reference
| Decision | Recommendation |
|---|---|
| Which changes need a new version? | Breaking ones only: removed or renamed fields, type changes, new required input, changed errors |
| URI or header versioning? | URI by default; header only if clean URLs are a hard requirement |
| Where do version files load? | From api.php with Route::prefix('vN')->name('vN.'); use then: only for files needing different middleware |
| What do you version? | The response transformation (API resources), not models or services |
| Why name-prefix each version? | Duplicate route names make route:cache fail |
| Header-based version and caching | Send Vary: Accept; never choose route files from the header at boot |
| Retiring a version | Sunset header and migration link, log usage, remove only when traffic is gone |
| Subdomain routes | Register them before root-domain routes |
Before you ship it
Decide what counts as a breaking change before you need to. Give every version its own name prefix so the route cache keeps working. Version the resources, not the application. Put a Sunset header on the old version on the day the new one launches, not on the day you want to remove it. And run php artisan route:cache in CI so a naming collision is caught before it reaches a deploy.
Related reading: this series starts with Part 1: Laravel Routing Basics, followed by Part 2: Route Model Binding, Part 3: Middleware, and Part 4: Route Caching.
Originally published on DEV Talk.
Top comments (0)