juliomotol/lapiv
Lapiv is a Laravel API versioning package that helps you organize versioned routes and controllers, resolve API versions from requests, and ship breaking changes without breaking existing clients. Simple configuration keeps evolving APIs clean and maintainable.
lapiv adds API versioning to Laravel's router without forcing /v1/, /v2/ URL prefixes. Versions are declared once as an enum, and routes are registered under a version; the package resolves which version the incoming request wants and only those routes are considered.
Install and publish the config:
composer require juliomotol/lapiv
php artisan vendor:publish --tag=lapiv-config
This gives you config/lapiv.php — the only file you should normally need to touch (resolvers, default version, header/query key names).
Create the version enum (the package ships a generator; otherwise hand-write a string-backed enum in app/ApiVersion.php):
enum ApiVersion: string {
case v1 = 'v1';
case v2 = 'v2';
}
Look here first when something version-related misbehaves — casing of the enum values is what clients must send.
Wrap routes in the apiVersion macro, either inline or around a per-version route file:
Route::apiVersion(ApiVersion::v1)->group(base_path('routes/api/v1.php'));
Route::apiVersion(ApiVersion::v2)->group(base_path('routes/api/v2.php'));
Laravel 11 note (v3.2.0): with the slim skeleton there is no RouteServiceProvider. Register the versioned route files inside bootstrap/app.php via ->withRouting(using: ...) (or a small service provider) and call $middleware config as usual. Package discovery handles the service provider; nothing else changes.
First use case: you have a live /api/users endpoint and need to ship a breaking change. Leave v1 routes untouched, copy the file to v2, register it under ApiVersion::v2, and let the resolver pick. Clients that send nothing keep hitting v1 (via default_version), so the change is opt-in.
One route file per version. Treat routes/api/v1.php, routes/api/v2.php as frozen contracts — never edit a shipped version file, only add to the newest one. This makes diffs reviewable and deprecation mechanical.
Resolution strategy is configuration, not code. The package's resolvers cover the usual suspects: query string (?version=v2), a custom header (e.g. X-Api-Version), and Accept-header content negotiation. Enable/order them in config/lapiv.php; the first resolver to return a value wins. For public APIs, lead with Accept (application/vnd.yourapp.v2+json); for internal/JS clients, the header is easier to set globally in an HTTP client.
Custom resolver. When none of the built-ins fit (subdomain, tenant setting, JWT claim), implement the package's resolver contract — a single method that takes the Request and returns an ApiVersion or null — and append the class to the resolvers array. Keep it pure and side-effect free; it runs early and often.
Reject unsupported versions explicitly. A resolver returning null usually falls back to the default version, which can silently serve old data to a client asking for v9. Add a middleware that checks the requested version against the enum and returns 400/406 (or 426 Upgrade Required with a Sunset header for retired versions).
Never couple controllers across versions. App\Http\Controllers\Api\V1\UserController and ...\V2\UserController should be independent even when 90% identical. Share behaviour via actions/services, not inheritance from a V1 base class — the moment v1 must not change, inheritance becomes a liability.
Route names must be version-prefixed. ->name('v1.users.index'), ->name('v2.users.index'). Unprefixed names collide because both versions register a users.index, and route('users.index') will resolve to whichever registered last.
Testing. Feature tests just set the header or query param:
$this->withHeader('Accept', 'application/vnd.yourapp.v2+json')
->getJson('/api/users');
Add one test per version per endpoint, plus a test asserting an unknown version is rejected — that contract is otherwise invisible until a client complains.
Deprecation workflow. Keep the old version registered, add a Sunset/Deprecation response header via middleware, log usage keyed by version, and remove the route file only when traffic metrics are zero.
v1 vs 1 vs V1 — clients send strings, enum values are your contract. Document the literal format; don't accept both unless a resolver normalises it.php artisan route:cache can behave differently from a fresh boot. After changing config/lapiv.php, enum cases, or route files, run route:clear and config:clear — a stale cached config is the most common "the package is broken" cause.api middleware group.404 rather than a version error, which sends clients debugging the wrong thing. Wrap with the rejecting middleware so the failure is unambiguous./api/v1/... and Accept: ...v2 produces routes that can never match and very confusing logs.Sunset/Deprecation signalling. Both are small and leave your controllers untouched.ApiVersion + the matched route name) — nearly every issue is a resolver returning null and the default silently taking over.RouteServiceProvider logic (including Route::apiVersion() calls) actually moved into bootstrap/app.php — a silently dropped registration shows up as a 404 on every version.How can I help you explore Laravel packages today?