DEV Community

Cover image for Laravel API Versioning Strategies That Don’t Suck
Saqueib Ansari
Saqueib Ansari

Posted on • Originally published at qcode.in

Laravel API Versioning Strategies That Don’t Suck

Most teams should avoid “v1/ v2” branching the entire Laravel codebase. It looks clean on paper, but it quietly doubles your maintenance surface area and makes backward compatibility harder, not easier. A better default is: keep one codebase, version at the edges, and evolve your API using additive changes, explicit deprecations, and small compatibility shims when you must.

That recommendation holds for the majority of product APIs: mobile apps, SPAs, partner integrations, and internal services that you control. Only reach for hard version splits when you’re forced to break contracts (auth model changes, resource identity changes, or semantic shifts that can’t be expressed as additive fields).

This article is opinionated and practical: how to design Laravel API versioning that keeps clients moving without turning your app into a museum of old controllers.

The failure mode: “/api/v1” everywhere, duplicated controllers, and two realities

Laravel makes it easy to slap a prefix on routes:

Route::prefix('api/v1')->group(function () {
    Route::get('/users/{user}', [V1\UserController::class, 'show']);
});

Route::prefix('api/v2')->group(function () {
    Route::get('/users/{user}', [V2\UserController::class, 'show']);
});
Enter fullscreen mode Exit fullscreen mode

The first month feels productive. Then reality hits:

  • You fix a bug in v2 and forget v1.
  • You add a field in v2 and now serializers diverge.
  • You change validation rules and now you have to reason about “which version is correct”.
  • You end up with two sets of policies, resources, requests, docs, tests, and support tickets.

The deeper problem is that route prefixes don’t define versioningcontracts do. If the contract is “a user has an id and email”, you can keep that contract stable without cloning controllers. Conversely, if you change the meaning of id or how authorization works, the prefix won’t save you from breaking clients.

So the goal isn’t “have versions”. The goal is:

  1. Backward-compatible evolution by default.
  2. Predictable breaking changes when required.
  3. Minimal overhead in code, docs, and tests.

Choose a versioning strategy based on what you’re actually changing

There are three common strategies for public-ish JSON APIs. In Laravel, all three are viable, but only one is a good default.

URL versioning (e.g., /v1)

When it wins:

  • You have large, unavoidable breaking changes.
  • You need to run two contracts for a long time.
  • You have external clients you can’t force-upgrade.

Where it fails:

  • Encourages “forked API” thinking.
  • Creates duplication pressure (controllers, resources, docs).
  • Makes it tempting to ship breaking changes in v2 instead of designing additive evolution.

Header-based versioning (e.g., Accept: application/vnd…)

Example:

  • Accept: application/vnd.qcode.users+json; version=2

When it wins:

  • You want stable URLs and better cache keys in some gateways.
  • You have a mature API platform and strong client discipline.

Where it fails:

  • Harder to debug manually.
  • Some clients and tools are clumsy with custom media types.
  • If you don’t enforce it consistently, you end up with “hidden versions”.

“No explicit version” + compatibility rules (recommended default)

This is the pragmatic approach for most product teams:

  • Keep routes stable (/api/users/{id}), no version in the URL.
  • Make additive changes (new optional fields, new endpoints).
  • Use feature flags or capabilities when semantics vary.
  • Deprecate with headers and timelines.

When it wins:

  • You control most clients (your mobile app, your SPA).
  • You want minimal code overhead.
  • You want to avoid long-lived parallel APIs.

Where it fails:

  • If you truly must break semantics (not just shape).
  • If you have many third-party integrators with unpredictable upgrade cycles.

If you’re unsure, start with “no explicit version” and design for compatibility. Add explicit versioning only when you hit a real breaking wall.

The Laravel pattern: version at the boundary, not the core

Even if you decide to support multiple versions, the cleanest architecture is:

  • Domain/services: one set of business logic.
  • Requests/validation: minimal version-specific differences.
  • Resources/transformers: where version differences usually belong.
  • Routes: detect version, then choose the right transformer.

In other words: keep the “truth” in one place, and treat versioning as a presentation concern.

Example 1: Header-based version negotiation + versioned Resources (minimal duplication)

Let’s implement a simple version negotiation layer in Laravel. We’ll support:

  • Default version: 1
  • Client can send X-API-Version: 2

Step 1: Middleware to resolve API version

<?php

namespace App\Http\Middleware;

use Closure;
use Illuminate\Http\Request;

class ResolveApiVersion
{
    public function handle(Request $request, Closure $next)
    {
        $version = (int) $request->header('X-API-Version', 1);

        // Clamp to supported range
        if ($version < 1) $version = 1;
        if ($version > 2) $version = 2;

        // Make it accessible everywhere
        app()->instance('api.version', $version);

        // Helpful for debugging and support
        $response = $next($request);
        $response->headers->set('X-API-Version', (string) $version);

        return $response;
    }
}
Enter fullscreen mode Exit fullscreen mode

Register it for your API group in app/Http/Kernel.php (Laravel 11 still supports middleware registration patterns; adjust for your app’s structure).

Step 2: One controller, version-aware Resources

Controller stays stable:

<?php

namespace App\Http\Controllers\Api;

use App\Http\Controllers\Controller;
use App\Http\Resources\User\UserResourceV1;
use App\Http\Resources\User\UserResourceV2;
use App\Models\User;

class UserController extends Controller
{
    public function show(User $user)
    {
        $version = app('api.version');

        return match ($version) {
            2 => new UserResourceV2($user),
            default => new UserResourceV1($user),
        };
    }
}
Enter fullscreen mode Exit fullscreen mode

Now the version differences live where they belong: the representation.

UserResourceV1:

<?php

namespace App\Http\Resources\User;

use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\JsonResource;

class UserResourceV1 extends JsonResource
{
    public function toArray(Request $request): array
    {
        return [
            'id' => (string) $this->id,
            'email' => $this->email,
            'name' => $this->name,
        ];
    }
}
Enter fullscreen mode Exit fullscreen mode

UserResourceV2 adds fields and changes naming without breaking v1:

<?php

namespace App\Http\Resources\User;

use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\JsonResource;

class UserResourceV2 extends JsonResource
{
    public function toArray(Request $request): array
    {
        return [
            'id' => (string) $this->id,
            'email' => $this->email,
            'display_name' => $this->name,
            'avatar_url' => $this->avatar_url,
            'created_at' => $this->created_at?->toISOString(),
        ];
    }
}
Enter fullscreen mode Exit fullscreen mode

Why this is low-overhead:

  • One route, one controller, one policy.
  • Versioning is mostly in Resources.
  • You can backport bug fixes to both versions automatically.

Common failure mode: trying to put version checks everywhere (“if v2 then …”) inside services. That’s how your domain becomes unreadable. Keep version checks at the boundary.

Backward compatibility rules that actually work in production

Versioning is mostly about discipline. These rules prevent 80% of breakage.

Prefer additive changes; treat removals as breaking forever

Adding a new optional field is cheap. Removing or renaming a field is expensive because some client somewhere will keep reading it.

  • Add: avatar_url (safe)
  • Add: metadata object (safe)
  • Remove: name (breaking)
  • Change type: id from string to int (breaking)

If you want to “remove” something, deprecate it first and keep it available until you have a hard cutoff.

Never change meaning under the same key

Changing semantics is worse than changing shape.

Bad: status used to mean “account status”, now means “subscription status”.

If semantics change, ship a new field (account_status, subscription_status) and deprecate the old one.

Be strict about request validation, but tolerant in responses

For requests:

  • Validate hard. Reject unknown enum values.
  • Be explicit about constraints.

For responses:

  • Clients should ignore unknown fields. Your API should assume they will.

This is why JSON:API and similar specs push predictable patterns, but you don’t need to fully adopt a spec to follow the principle.

Use explicit deprecation signals

If you’re serious about stability, communicate deprecations in-band:

  • Deprecation: true
  • Sunset: <http-date> (RFC 8594)
  • Link: <https://docs.example.com/deprecations/v1>; rel="deprecation"

Laravel makes this easy at the middleware level.

Official reference for the Sunset header: https://datatracker.ietf.org/doc/html/rfc8594

Example 2: “Compatibility shim” for a breaking request change (without forking endpoints)

A common breaking change is request shape. Example: you originally accepted:

{ "name": "Asha", "email": "asha@example.com" }
Enter fullscreen mode Exit fullscreen mode

Later you want:

{ "profile": { "display_name": "Asha" }, "email": "asha@example.com" }
Enter fullscreen mode Exit fullscreen mode

Forking /v2/users is the obvious move. A lower-overhead approach is a request normalization shim that maps old payloads into the new internal shape.

Step 1: Middleware to normalize input based on version

<?php

namespace App\Http\Middleware;

use Closure;
use Illuminate\Http\Request;

class NormalizeUserPayload
{
    public function handle(Request $request, Closure $next)
    {
        if (!$request->isMethod('post') || !$request->is('api/users')) {
            return $next($request);
        }

        $version = app('api.version');

        if ($version === 1) {
            // Convert v1 payload to v2 internal contract
            $name = $request->input('name');

            $request->merge([
                'profile' => array_merge(
                    $request->input('profile', []),
                    ['display_name' => $request->input('profile.display_name', $name)]
                ),
            ]);
        }

        return $next($request);
    }
}
Enter fullscreen mode Exit fullscreen mode

Step 2: Validate only the new shape in a Form Request

<?php

namespace App\Http\Requests;

use Illuminate\Foundation\Http\FormRequest;

class StoreUserRequest extends FormRequest
{
    public function rules(): array
    {
        return [
            'email' => ['required', 'email'],
            'profile.display_name' => ['required', 'string', 'min:2'],
        ];
    }
}
Enter fullscreen mode Exit fullscreen mode

Step 3: Controller uses the normalized contract

<?php

namespace App\Http\Controllers\Api;

use App\Http\Controllers\Controller;
use App\Http\Requests\StoreUserRequest;
use App\Models\User;

class UsersController extends Controller
{
    public function store(StoreUserRequest $request)
    {
        $user = User::create([
            'email' => $request->input('email'),
            'name' => $request->input('profile.display_name'),
        ]);

        return response()->json(['id' => (string) $user->id], 201);
    }
}
Enter fullscreen mode Exit fullscreen mode

Tradeoff: shims can accumulate if you keep them forever. The point is to buy time for migration, not to preserve every legacy contract indefinitely.

A practical policy:

  • Shims are allowed only when paired with a Sunset date.
  • Shims must be isolated to middleware/transformers, not services.

Testing and documentation: where versioning usually collapses

Versioning fails when it isn’t testable and observable.

Contract tests per version (cheap, high leverage)

You don’t need full duplicated test suites. You need a thin set of contract tests for the responses that clients depend on.

In PHPUnit/Pest, test the same endpoint with different headers:

it('returns v1 user shape', function () {
    $user = \App\Models\User::factory()->create(['name' => 'Asha']);

    $this->withHeader('X-API-Version', '1')
        ->getJson("/api/users/{$user->id}")
        ->assertOk()
        ->assertJsonStructure(['id', 'email', 'name'])
        ->assertJsonMissing(['display_name', 'avatar_url']);
});

it('returns v2 user shape', function () {
    $user = \App\Models\User::factory()->create(['name' => 'Asha']);

    $this->withHeader('X-API-Version', '2')
        ->getJson("/api/users/{$user->id}")
        ->assertOk()
        ->assertJsonStructure(['id', 'email', 'display_name', 'created_at']);
});
Enter fullscreen mode Exit fullscreen mode

This catches accidental breakage when someone “cleans up” a resource.

Document deprecations like product decisions, not footnotes

If you have an API docs site (OpenAPI/Swagger), don’t just mark things deprecated and move on. Add:

  • What replaces it
  • The cutoff date
  • The client action required

If you’re using OpenAPI, you can model versioning either as separate specs or as one spec with versioned schemas. Many teams do better with separate specs once they truly diverge—but that’s exactly the point: don’t diverge until you must.

Official OpenAPI spec: https://spec.openapis.org/oas/latest.html

Observability: log version usage before you remove anything

Before you sunset v1:

  • Log api.version, route name, and client identifier.
  • Create a dashboard: “Requests by version over time”.

In Laravel, you can attach this to middleware and ship to whatever you use (OpenTelemetry, Sentry, Datadog, ELK). The exact stack matters less than the discipline: if you can’t measure usage, you can’t deprecate safely.

When you truly need a hard v2 (and how to do it without a mess)

Sometimes you must break:

  • Auth changes (e.g., moving from API keys to OAuth2 scopes)
  • Resource identity changes (/users/{id} becomes /accounts/{uuid})
  • Pagination semantics change (offset → cursor)
  • A field’s meaning changes and can’t be expressed additively

In those cases, do URL versioning, but still avoid duplicating the entire stack.

Practical pattern:

  • Keep shared domain/services.
  • Keep shared policies where possible.
  • Use separate route files: routes/api_v1.php, routes/api_v2.php.
  • Keep controllers thin; put logic in services.
  • Version the Resources and Requests more than the business logic.

A clean route setup:

// routes/api.php
Route::middleware(['api', \App\Http\Middleware\ResolveApiVersion::class])
    ->group(function () {
        require base_path('routes/api_shared.php');
    });

// If you truly need hard versions:
Route::prefix('api/v1')->middleware('api')->group(function () {
    require base_path('routes/api_v1.php');
});

Route::prefix('api/v2')->middleware('api')->group(function () {
    require base_path('routes/api_v2.php');
});
Enter fullscreen mode Exit fullscreen mode

The judgment call: if your v2 is “we renamed fields and cleaned up JSON”, you don’t need /v2. Use resources and shims. If your v2 is “the meaning of the workflow changed”, you probably do.

The decision rule most teams should adopt

If you want minimal overhead and long-term stability in a Laravel API, adopt this rule of thumb:

  • Default: no URL versioning; evolve via additive fields + explicit deprecation headers.
  • Allow: header-based negotiation when you need different representations.
  • Escalate to /v2 only when semantics break, not when you merely dislike the old shape.

And one warning that saves teams from years of pain: never let versioning leak into your domain layer. Keep it in middleware, request normalization, and resources. If your services start taking $version arguments, you’re building two products in one codebase—and you’ll pay for it every sprint.


Read the full post on QCode: https://qcode.in/laravel-api-versioning-designing-backward-compatible-apis-with-minimal-overhead/

Top comments (1)

Collapse
 
xwero profile image
david duymelinck

I do like the options you present. But there is something that feels off with the code examples.

The first example can be valid when you choose not to use a header and the code in the controllers differs that much it would create too much branching to keep the code in one controller.
Having different urls is not the main problem, the problems start when code is duplicated in controllers without too much thought.

With the example where the output shape changes, I don't think is is necessary to change the version. A version is a very abstract thing.
The problem could by solved by adding an optional key to the post or the query string. This is a more meaningful change.