laravel-api-versionist maintained by jayanta
Laravel API Versionist
Your API changes. Your controllers don't.
Inspired by Stripe's API versioning architecture — you write small transformer classes that describe what changed between versions. The package upgrades old requests and downgrades new responses automatically. Your controllers always speak the latest version.
Supports Laravel 10, 11, 12 & 13 · PHP 8.1–8.5
The problem, in short
You shipped v1. A mobile app depends on it. Now you need v2 with different field names. Your options are: duplicate every controller per version (bug fixes applied N times), scatter if/else version checks everywhere, or use this package — one controller, one version, transformers handle the rest.
// Your controller. Always latest version. That's it.
class UserController extends Controller
{
public function show(User $user)
{
return response()->json([
'handle' => $user->handle,
'roles' => $user->roles,
]);
}
}
A v1 client hits this endpoint and gets back username, role — automatically downgraded by the transformer you wrote once.
How it works
┌──────────┐ upgrade chain ┌────────────┐
│ v1 Client│ ───────────────► │ Controller │ ← always writes v3
│ Request │ v1 → v2 → v3 │ (latest) │
└──────────┘ └─────┬──────┘
│ v3 response
┌──────────┐ downgrade chain │
│ v1 Client│ ◄────────────────┌─────▼──────┐
│ Response │ v3 → v2 → v1 │ Response │
└──────────┘ └────────────┘
Old request comes in → upgraded to latest → controller processes it → response downgraded back to the client's version. Your controller never changes.
Installation
composer require jayanta/laravel-api-versionist
php artisan vendor:publish --tag=api-versionist-config
Laravel auto-discovers the service provider. No manual registration.
Supported versions
| Laravel | PHP | Tested against |
|---|---|---|
| 13.x | 8.3 – 8.5 | testbench 11, PHPUnit 12 |
| 12.x | 8.3 – 8.5 | testbench 10, PHPUnit 11 |
| 11.x | 8.2 – 8.4 | testbench 9, PHPUnit 11 |
| 10.x | 8.1 – 8.4 | testbench 8, PHPUnit 10 |
Quick start
1. Generate a transformer:
php artisan api:make-transformer v2
2. Define what changed:
// app/Api/Transformers/V2Transformer.php
final class V2Transformer extends ApiVersionTransformer
{
public function version(): string { return 'v2'; }
public function description(): string
{
return 'Renamed username to handle, converted role string to roles array.';
}
public function upgradeRequest(array $data): array
{
if (isset($data['username'])) {
$data['handle'] = $data['username'];
unset($data['username']);
}
if (isset($data['role']) && is_string($data['role'])) {
$data['roles'] = [$data['role']];
unset($data['role']);
}
return $data;
}
public function downgradeResponse(array $data): array
{
if (isset($data['handle'])) {
$data['username'] = $data['handle'];
unset($data['handle']);
}
if (isset($data['roles']) && is_array($data['roles'])) {
$data['role'] = $data['roles'][0] ?? 'user';
unset($data['roles']);
}
return $data;
}
}
3. Register it:
// config/api-versionist.php
'latest_version' => 'v2',
'transformers' => [
App\Api\Transformers\V2Transformer::class,
],
'response_data_key' => null, // null for flat JSON, 'data' for wrapped responses
If you leave latest_version as 'v1' after adding a V2Transformer, no transformers will run. Always update this value.
4. Add middleware:
// routes/api.php
Route::middleware('api.version')->group(function () {
Route::get('/users/{user}', [UserController::class, 'show']);
});
// or use the shorthand macro
Route::versioned()->group(function () { /* ... */ });
5. Test it:
curl -H "X-Api-Version: v2" http://your-app.test/api/users/1
# → {"handle": "janedoe", "roles": ["admin"]}
curl -H "X-Api-Version: v1" http://your-app.test/api/users/1
# → {"username": "janedoe", "role": "admin"}
Transformers in depth
Each transformer describes one version transition. A V2Transformer handles v1→v2. A V3Transformer handles v2→v3. The package chains them automatically.
final class V2Transformer extends ApiVersionTransformer
{
public function version(): string { return 'v2'; }
public function description(): string { return 'Renamed username to handle, role string to roles array.'; }
public function releasedAt(): ?string { return '2025-03-01'; }
public function upgradeRequest(array $data): array
{
if (isset($data['username'])) {
$data['handle'] = $data['username'];
unset($data['username']);
}
if (isset($data['role']) && is_string($data['role'])) {
$data['roles'] = [$data['role']];
unset($data['role']);
}
return $data;
}
public function downgradeResponse(array $data): array
{
if (isset($data['handle'])) {
$data['username'] = $data['handle'];
unset($data['handle']);
}
if (isset($data['roles']) && is_array($data['roles'])) {
$data['role'] = $data['roles'][0] ?? 'user';
unset($data['roles']);
}
return $data;
}
}
Fields you don't touch pass through unchanged. A request with age, city, custom_field keeps all of them — transformers only modify what they explicitly reference.
Attribute-based metadata
Instead of overriding version(), description(), and releasedAt(), you can declare the same metadata with the #[ApiVersion] class attribute:
use Versionist\ApiVersionist\Attributes\ApiVersion;
#[ApiVersion('v2', description: 'Renamed username to handle.', releasedAt: '2025-03-01')]
final class V2Transformer extends ApiVersionTransformer
{
public function upgradeRequest(array $data): array { /* ... */ }
public function downgradeResponse(array $data): array { /* ... */ }
}
Method overrides always win over the attribute, so existing transformers are unaffected.
Constructor injection
Transformers are resolved through Laravel's service container, so DI works:
final class V3Transformer extends ApiVersionTransformer
{
public function __construct(
private readonly UserRepository $repo
) {}
public function version(): string { return 'v3'; }
public function description(): string { return 'Added legacy_role lookup for v2 clients.'; }
public function downgradeResponse(array $data): array
{
if (isset($data['user_id'])) {
$data['legacy_role'] = $this->repo->getLegacyRole($data['user_id']);
}
return $data;
}
}
Keep DB access in transformers read-only and lightweight. If a transformer needs heavy queries, the version gap is probably too wide for this pattern.
When NOT to use transformers
Transformers handle data shape changes — field renames, restructures, type conversions.
For behavior changes, use the request macros the package provides:
if ($request->isApiVersionAtLeast('v3')) {
// new pricing logic, auth behavior, business rules
}
Transformers = structure. Application code = behavior. If your breaking change is behavioral, separate controllers are probably cleaner.
Multi-version example
Three versions, showing the full upgrade/downgrade chain:
| Version | Fields |
|---|---|
| v1 | username, role, is_active |
| v2 | handle, role, is_active |
| v3 | handle, roles[], status |
A v1 client sends: { "username": "janedoe", "role": "admin", "is_active": true }
Upgrade chain:
- V2Transformer:
username→handle - V3Transformer:
role→roles[],is_active→status - Controller receives:
{ "handle": "janedoe", "roles": ["admin"], "status": "active" }
Downgrade chain (response goes back through V3 then V2):
- V3Transformer:
roles[]→role,status→is_active - V2Transformer:
handle→username - v1 client receives:
{ "username": "janedoe", "role": "admin", "is_active": true }
Version detection
Four strategies, tried in config order. First match wins.
| Strategy | Example |
|---|---|
url_prefix |
GET /api/v2/users |
header |
X-Api-Version: v2 |
query_param |
GET /api/users?version=v2 |
accept_header |
Accept: application/vnd.api+json;version=2 |
'detection_strategies' => ['url_prefix', 'header', 'query_param'],
No version detected → falls back to default_version (usually 'v1'). Unknown version in strict mode → HTTP 400.
Configuration reference
| Key | Default | Description |
|---|---|---|
default_version |
'v1' |
Fallback when no version detected |
latest_version |
'v1' |
Must match your highest transformer |
transformers |
[] |
Array of transformer class names |
deprecated_versions |
[] |
Version → sunset date map ('v1' => '2025-12-31' or 'v1' => ['deprecated_at' => ..., 'sunset' => ...]) |
rfc_compliant_headers |
false |
Emit Sunset as RFC 8594 HTTP-date and Deprecation as RFC 9745 timestamp |
strict_mode |
false |
true = unknown versions throw HTTP 400 |
response_data_key |
'data' |
Key to transform in responses. null = entire body |
request_data_key |
null |
Key to transform in requests. null = entire body |
add_version_headers |
true |
Adds X-Api-Version and X-Api-Latest-Version headers |
detection_strategies |
[...] |
Ordered list of detection strategies |
header_name |
'X-Api-Version' |
Header name for header strategy |
query_param |
'version' |
Param name for query_param strategy |
changelog.enabled |
false |
Expose version metadata as JSON endpoint |
changelog.endpoint |
'/api/versions' |
Changelog URL path |
Important: The default response_data_key is 'data'. If your controller returns flat JSON (not wrapped in {"data": {...}}), set this to null or transformers won't touch the response.
Response headers
With add_version_headers enabled:
X-Api-Version: v2
X-Api-Latest-Version: v3
For deprecated versions:
Deprecation: true
Sunset: 2025-12-31
Link: </api/versions>; rel="successor-version"
Mark versions as deprecated in config:
'deprecated_versions' => [
'v1' => '2025-12-31',
'v2' => null, // deprecated, no sunset date yet
],
Headers follow RFC 8594. Deprecated versions still work normally.
Standards-compliant wire formats
By default the package emits the widely-used legacy formats shown above (Deprecation: true, Sunset as the raw configured date). Enable rfc_compliant_headers to switch to the standards-track formats:
'rfc_compliant_headers' => true,
'deprecated_versions' => [
'v1' => ['deprecated_at' => '2025-01-01', 'sunset' => '2025-06-01'],
],
Deprecation: @1735689600 ← RFC 9745 (unix timestamp)
Sunset: Sun, 01 Jun 2025 00:00:00 GMT ← RFC 8594 (HTTP-date)
The plain string form of deprecated_versions keeps working in both modes; without a deprecated_at date, Deprecation falls back to true.
Changelog endpoint
Enable changelog.enabled to register a JSON endpoint (route name api-versionist.changelog) that serves the same version metadata as php artisan api:changelog --format=json — minus internal transformer class names:
'changelog' => [
'enabled' => true,
'endpoint' => '/api/versions',
'middleware' => ['api'],
],
{
"baseline": "v1",
"latest": "v3",
"versions": [
{"version": "v1", "is_baseline": true, "deprecated": true, "sunset_date": "2025-06-01", ...},
{"version": "v2", ...},
{"version": "v3", "is_latest": true, ...}
]
}
This is the endpoint the Link: rel="successor-version" deprecation header points clients to.
Request macros
After the middleware runs, every Request gets these:
$request->apiVersion(); // "v2"
$request->isApiVersion('v2'); // true
$request->isApiVersionAtLeast('v2'); // true for v2, v3, v4...
$request->isApiVersionBefore('v3'); // true for v1, v2
Useful for version-specific behavior that doesn't belong in transformers:
if ($request->isApiVersionAtLeast('v3')) {
$users->each(fn ($user) => $user->append('login_streak'));
}
Artisan commands
# Scaffold a new transformer
php artisan api:make-transformer v4
# List all registered versions with status
php artisan api:versions
php artisan api:versions --chains # show upgrade/downgrade chains
# Human-readable changelog
php artisan api:changelog
php artisan api:changelog --format=json
# Validate transformers and dry-run the pipeline
php artisan api:audit --from=v1 --to=v3
Envelope mode
If your API wraps responses in {"data": {...}, "meta": {...}}, set response_data_key to 'data' so transformers only touch the data portion:
'response_data_key' => 'data',
'request_data_key' => 'data',
Only "data" gets transformed. "meta", "links", pagination — all untouched.
If your controller returns flat JSON, set both to null.
Known limitations
Flat array responses — If you return [{...}, {...}] (a list of objects), the transformer sees the array, not individual items. Wrap lists in {"data": [...]} and use response_data_key => 'data'.
Nested keys — The package transforms one location (top-level or response_data_key). It won't recursively walk {"user": {...}, "company": {...}}. Keep transformable data at one level.
Lossy transforms — Some field changes can't be perfectly reversed. Converting an array to a scalar (e.g. roles[] → role) drops extra elements. Design transforms that round-trip cleanly, or accept the data loss and document it.
When separate controllers are the better choice
This pattern works best for structural changes — field renames, payload reshaping, type conversions.
Consider separate controllers when:
- Changes are behavioral, not structural (different auth, pricing, business rules)
- Version differences are so large that transformers become hard to follow
- You're maintaining very old legacy systems where scattered logic makes debugging harder
No universally correct approach. Transformers reduce duplication for structural versioning. Separate controllers give clearer isolation when behavior diverges.
Testing
Transformers are plain PHP — test them directly:
class V2TransformerTest extends TestCase
{
private V2Transformer $transformer;
protected function setUp(): void
{
$this->transformer = new V2Transformer();
}
public function test_upgrade_renames_username_to_handle(): void
{
$result = $this->transformer->upgradeRequest([
'username' => 'janedoe',
'role' => 'admin',
]);
$this->assertSame('janedoe', $result['handle']);
$this->assertSame(['admin'], $result['roles']);
$this->assertArrayNotHasKey('username', $result);
}
public function test_round_trip_preserves_data(): void
{
$original = ['username' => 'janedoe', 'role' => 'admin'];
$upgraded = $this->transformer->upgradeRequest($original);
$downgraded = $this->transformer->downgradeResponse($upgraded);
$this->assertSame($original, $downgraded);
}
}
For transformers with constructor injection, mock the dependency:
public function test_v3_downgrade_adds_legacy_role(): void
{
$repo = $this->createMock(UserRepository::class);
$repo->method('getLegacyRole')->with(42)->willReturn('editor');
$transformer = new V3Transformer($repo);
$result = $transformer->downgradeResponse(['user_id' => 42, 'handle' => 'jane']);
$this->assertSame('editor', $result['legacy_role']);
}
Or validate everything at once: php artisan api:audit
FAQ
Do I need to change my controllers for every version? No. Controllers always return the latest version. You write one transformer per version step.
What if v1 and v3 are completely different? Write one transformer per step. V2Transformer handles v1→v2, V3Transformer handles v2→v3. The package chains them. You never write a combined v1→v3 transformer.
Can I use date-based versions like 2024-01-15?
Yes. The parser accepts both numeric (v1, v2.1) and date-based (2024-01-15) formats. Dates are compared chronologically. You can even mix them — date versions sort higher than numeric ones.
Prior art
This pattern was publicly documented by Stripe in 2017 (Brandur Leach), adopted by Intercom in 2018, and open-sourced for Ruby by Keygen. This package brings the same idea to Laravel.
How it compares
| Versionist | request-migrations | api-evolution | vermillion | |
|---|---|---|---|---|
| Laravel 10–13 / PHP 8.5 | ✅ | ❌ (dormant, Laravel ≤6 era) | partial | partial |
| Detection strategies (URL/header/Accept/query) | ✅ 4 | header only | header only | ✅ |
| RFC 8594 + RFC 9745 deprecation headers | ✅ | ❌ | ❌ | ❌ |
| JSON changelog endpoint | ✅ | ❌ | ❌ | ❌ |
| Attribute-based registration | ✅ | ❌ | ❌ | ❌ |
Transformer audit CLI (api:audit) |
✅ | ❌ | ❌ | ❌ |
| Date-based versions (Stripe-style) | ✅ | ❌ | ❌ | ✅ |
Coming from request-migrations? See the migration guide.
Credits
Contributing
Contributions welcome:
- Fork the repository
- Create a feature branch
- Write tests — all PRs must include tests
- Run
composer test - Submit a pull request
git clone https://github.com/jay123anta/laravel-api-versionist.git
cd laravel-api-versionist
composer install
composer test
License
MIT. See LICENSE for details.