Versioning and Documentation
You can deploy your API but not the clients that call it, so breaking changes need a new version and docs need generating.
Route::middleware('api')->prefix('api/v2')->name('api.v2.')->group(base_path('routes/api_v2.php'));You deploy your API tonight. You cannot deploy the mobile app a user installed last year, or the integration a customer wrote and forgot. A version exists for that gap: the old shape keeps answering while clients move at their own pace.
What counts as breaking
Removing or renaming a field. Changing its type, including "12" to 12. Changing a status code. Adding a required parameter, or tightening a validation rule. Changing the default sort order or page size. Each of those can turn a working client into an error without the client changing a line. Adding an optional parameter, or a new field, is usually safe, on the assumption that consumers ignore keys they do not recognise. This is the practical argument for resource classes: the resource is the written list of fields you have promised, in one file you can diff.
Where the version lives
The simplest version is a URI segment. apiPrefix in withRouting() sets one prefix for the whole routes/api.php file, so it moves everything at once. To serve two versions, register a second route file in the then: closure of withRouting(), and give each version its own controller and resource namespace, so editing v2 cannot reach into v1.
Documentation
Hand-written endpoint docs drift within a sprint. Generate them from what is true instead: the routes, the form requests, the resources. php artisan route:list --path=api --json is the route inventory, machine-readable. Laravel 13 ships no OpenAPI generator, so the document comes from a package or from your own command; either way it belongs in version control, regenerated in CI, so a changed response breaks a build rather than a customer.
Example
<?php
// bootstrap/app.php
use Illuminate\Foundation\Application;
use Illuminate\Foundation\Configuration\Exceptions;
use Illuminate\Foundation\Configuration\Middleware;
use Illuminate\Support\Facades\Route;
return Application::configure(basePath: dirname(__DIR__))
->withRouting(
web: __DIR__.'/../routes/web.php',
// v1 keeps the api file and gains the version in its prefix.
api: __DIR__.'/../routes/api_v1.php',
apiPrefix: 'api/v1',
commands: __DIR__.'/../routes/console.php',
health: '/up',
// Every further version is registered here. The api middleware
// group and the prefix are now explicit, because only the file
// passed as `api:` gets them applied for free.
then: function (): void {
Route::middleware('api')
->prefix('api/v2')
->name('api.v2.')
->group(base_path('routes/api_v2.php'));
},
)
->withMiddleware(function (Middleware $middleware): void {
$middleware->throttleApi();
})
->withExceptions(function (Exceptions $exceptions): void {
//
})->create();
When to use it
- Change a field's type for new clients while the installed mobile app keeps working.
- Give integrators a dated Sunset header before an old version is removed.
- Decide whether a pull request needs a new version or just a deploy.
- Keep an endpoint list in the repository that cannot drift from the routes.
More examples
One namespace per version
Duplicating the controller and the resource per version feels wasteful until the first time a shared class is edited for v2 and silently changes v1. Shared logic belongs below the controller, in a model method or a service class, not in a class the response shape depends on.
<?php
// routes/api_v1.php — frozen. Bug fixes only.
use App\Http\Controllers\Api\V1\PostController;
Route::apiResource('posts', PostController::class);
// routes/api_v2.php — where the change lands.
use App\Http\Controllers\Api\V2\PostController;
Route::apiResource('posts', PostController::class);
// app/Http/Resources/V1/PostResource.php — the promise v1 made.
// 'author' => $this->author->name, // a string
// app/Http/Resources/V2/PostResource.php — the change that forced v2.
// 'author' => new UserResource($this->whenLoaded('author')), // an object
// The directory layout:
//
// app/Http/Controllers/Api/V1/PostController.php
// app/Http/Controllers/Api/V2/PostController.php
// app/Http/Resources/V1/PostResource.php
// app/Http/Resources/V2/PostResource.php
// routes/api_v1.php
// routes/api_v2.php
Telling clients a version is going away
Use this the day v2 ships, not the week before v1 is switched off. The header travels with every response, so a client's own logs show the warning even if the integration's author left the company.
<?php
// app/Http/Middleware/AnnounceDeprecation.php
// Generated by: php artisan make:middleware AnnounceDeprecation
declare(strict_types=1);
namespace App\Http\Middleware;
use Closure;
use Illuminate\Http\Request;
use Symfony\Component\HttpFoundation\Response;
class AnnounceDeprecation
{
public function handle(Request $request, Closure $next, string $sunset): Response
{
$response = $next($request);
// RFC 8594. A date, not a promise in a changelog nobody reads.
$response->headers->set('Deprecation', 'true');
$response->headers->set('Sunset', $sunset);
$response->headers->set('Link', '</api/v2/docs>; rel="successor-version"');
return $response;
}
}
// bootstrap/app.php
// $middleware->alias(['deprecated' => AnnounceDeprecation::class]);
// routes/api_v1.php
Route::middleware('deprecated:Wed, 01 Jul 2026 00:00:00 GMT')->group(function (): void {
Route::apiResource('posts', PostController::class);
});
The route inventory, as data
Commit the output and a route that disappeared becomes a line in a code review rather than a support ticket. It is also the cheapest input to generated documentation: the URIs and names are facts, not prose.
# Every API route, machine-readable. Diff this between deploys.
php artisan route:list --path=api --json | jq '.[] | {method, uri, name}'
# {
# "method": "GET|HEAD",
# "uri": "api/v1/posts",
# "name": "posts.index"
# }
# {
# "method": "GET|HEAD",
# "uri": "api/v2/posts",
# "name": "api.v2.posts.index"
# }
# Hide package routes, so the list is only what you promised.
php artisan route:list --path=api --except-vendor --json > docs/routes.json
Discussion