PHPackages                             jayanta/laravel-api-versionist - PHPackages - PHPackages  [Skip to content](#main-content)[PHPackages](/)[Directory](/)[Categories](/categories)[Trending](/trending)[Leaderboard](/leaderboard)[Changelog](/changelog)[Analyze](/analyze)[Collections](/collections)[Log in](/login)[Sign up](/register)

1. [Directory](/)
2. /
3. [HTTP &amp; Networking](/categories/http)
4. /
5. jayanta/laravel-api-versionist

ActiveLibrary[HTTP &amp; Networking](/categories/http)

jayanta/laravel-api-versionist
==============================

Elegant API versioning for Laravel — transform requests and responses across versions automatically.

v1.0.0(3mo ago)1203MITPHPPHP ^8.1CI passing

Since Mar 7Pushed 2mo agoCompare

[ Source](https://github.com/jay123anta/laravel-api-versionist)[ Packagist](https://packagist.org/packages/jayanta/laravel-api-versionist)[ RSS](/packages/jayanta-laravel-api-versionist/feed)WikiDiscussions main Synced 3w ago

READMEChangelog (1)Dependencies (6)Versions (2)Used By (0)

Laravel API Versionist
======================

[](#laravel-api-versionist)

**Your API changes. Your controllers don't.**

[![Latest Version on Packagist](https://camo.githubusercontent.com/0891f3072b45261a231395a8545b1556a5576cae91af462bd250e90cd78381f5/68747470733a2f2f696d672e736869656c64732e696f2f7061636b61676973742f762f6a6179616e74612f6c61726176656c2d6170692d76657273696f6e697374)](https://packagist.org/packages/jayanta/laravel-api-versionist)[![PHP Version](https://camo.githubusercontent.com/e4e5cc0ff7a0c11b4ee734b2e02bdd1ca76e1b92002b12c8e768168bc0c71508/68747470733a2f2f696d672e736869656c64732e696f2f7061636b61676973742f7068702d762f6a6179616e74612f6c61726176656c2d6170692d76657273696f6e697374)](https://packagist.org/packages/jayanta/laravel-api-versionist)[![Tests](https://github.com/jay123anta/laravel-api-versionist/actions/workflows/tests.yml/badge.svg)](https://github.com/jay123anta/laravel-api-versionist/actions/workflows/tests.yml)[![License](https://camo.githubusercontent.com/9ac3e01939f676f5decac95f77d9f0c0ba89986f7165b59a4a67b6438d6ce96f/68747470733a2f2f696d672e736869656c64732e696f2f7061636b61676973742f6c2f6a6179616e74612f6c61726176656c2d6170692d76657273696f6e697374)](https://packagist.org/packages/jayanta/laravel-api-versionist)[![Total Downloads](https://camo.githubusercontent.com/62834978fa825642779722630e2c8d321fe1831a7e1ab3bf7f0e905e3a7452d6/68747470733a2f2f696d672e736869656c64732e696f2f7061636b61676973742f64742f6a6179616e74612f6c61726176656c2d6170692d76657273696f6e697374)](https://packagist.org/packages/jayanta/laravel-api-versionist)

Inspired by [Stripe's API versioning architecture](https://stripe.com/blog/api-versioning) — 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 &amp; 12 · PHP 8.1+

---

The problem, in short
---------------------

[](#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
------------

[](#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
------------

[](#installation)

```
composer require jayanta/laravel-api-versionist
php artisan vendor:publish --tag=api-versionist-config
```

Laravel auto-discovers the service provider. No manual registration.

---

Quick start
-----------

[](#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
---------------------

[](#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.

### Constructor injection

[](#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
----------------------------

[](#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
---------------------

[](#multi-version-example)

Three versions, showing the full upgrade/downgrade chain:

VersionFieldsv1`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:**

1. V2Transformer: `username` → `handle`
2. V3Transformer: `role` → `roles[]`, `is_active` → `status`
3. Controller receives: `{ "handle": "janedoe", "roles": ["admin"], "status": "active" }`

**Downgrade chain** (response goes back through V3 then V2):

1. V3Transformer: `roles[]` → `role`, `status` → `is_active`
2. V2Transformer: `handle` → `username`
3. v1 client receives: `{ "username": "janedoe", "role": "admin", "is_active": true }`

---

Version detection
-----------------

[](#version-detection)

Four strategies, tried in config order. First match wins.

StrategyExample`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
-----------------------

[](#configuration-reference)

KeyDefaultDescription`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'`)`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
----------------

[](#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: ; 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](https://tools.ietf.org/html/rfc8594). Deprecated versions still work normally.

---

Request macros
--------------

[](#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
----------------

[](#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
-------------

[](#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
-----------------

[](#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

[](#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
-------

[](#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
---

[](#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
---------

[](#prior-art)

This pattern was [publicly documented by Stripe in 2017](https://stripe.com/blog/api-versioning) (Brandur Leach), adopted by [Intercom in 2018](https://www.intercom.com/blog/api-versioning/), and open-sourced for Ruby by [Keygen](https://github.com/keygen-sh/request_migrations). This package brings the same idea to Laravel.

---

Credits
-------

[](#credits)

- [Jay Anta](https://github.com/jay123anta)

---

Contributing
------------

[](#contributing)

Contributions welcome:

1. Fork the [repository](https://github.com/jay123anta/laravel-api-versionist)
2. Create a feature branch
3. Write tests — all PRs must include tests
4. Run `composer test`
5. Submit a pull request

```
git clone https://github.com/jay123anta/laravel-api-versionist.git
cd laravel-api-versionist
composer install
composer test
```

---

License
-------

[](#license)

MIT. See [LICENSE](LICENSE) for details.

###  Health Score

37

—

LowBetter than 81% of packages

Maintenance81

Actively maintained with recent releases

Popularity9

Limited adoption so far

Community8

Small or concentrated contributor base

Maturity43

Maturing project, gaining track record

 Bus Factor1

Top contributor holds 100% of commits — single point of failure

How is this calculated?**Maintenance (25%)** — Last commit recency, latest release date, and issue-to-star ratio. Uses a 2-year decay window.

**Popularity (30%)** — Total and monthly downloads, GitHub stars, and forks. Logarithmic scaling prevents top-heavy scores.

**Community (15%)** — Contributors, dependents, forks, watchers, and maintainers. Measures real ecosystem engagement.

**Maturity (30%)** — Project age, version count, PHP version support, and release stability.

###  Release Activity

Cadence

Unknown

Total

1

Last Release

109d ago

### Community

Maintainers

![](https://avatars.githubusercontent.com/u/62276865?v=4)[Jayanta Kumar Nath](/maintainers/jay123anta)[@jay123anta](https://github.com/jay123anta)

---

Top Contributors

[![jay123anta](https://avatars.githubusercontent.com/u/62276865?v=4)](https://github.com/jay123anta "jay123anta (17 commits)")

---

Tags

apicomposerlaravelmiddlewarephprest-apitransformerversioningapilaravelrestversioningtransformer

###  Code Quality

TestsPHPUnit

### Embed Badge

![Health badge](/badges/jayanta-laravel-api-versionist/health.svg)

```
[![Health](https://phpackages.com/badges/jayanta-laravel-api-versionist/health.svg)](https://phpackages.com/packages/jayanta-laravel-api-versionist)
```

###  Alternatives

[psalm/plugin-laravel

Psalm plugin for Laravel

3345.1M337](/packages/psalm-plugin-laravel)[api-platform/laravel

API Platform support for Laravel

59156.3k11](/packages/api-platform-laravel)[laravel/mcp

Rapidly build MCP servers for your Laravel applications.

76518.2M120](/packages/laravel-mcp)[laravel/surveyor

Static analysis tool for Laravel applications.

8690.3k12](/packages/laravel-surveyor)[fleetbase/core-api

Core Framework and Resources for Fleetbase API

1232.2k16](/packages/fleetbase-core-api)[calebdw/larastan

Larastan - Discover bugs in your code without running it. A phpstan/phpstan extension for Laravel

15104.9k4](/packages/calebdw-larastan)

PHPackages © 2026

[Directory](/)[Categories](/categories)[Trending](/trending)[Changelog](/changelog)[Analyze](/analyze)
