log in
consulting hosting industries the daily tools about contact

API Versioning in Laravel: The Operational Pain Nobody Warns You About

URI vs. header versioning both work until they don't. Here's where each one actually burns you in production.

Every architecture post on API versioning draws the same clean diagram, explains the two main camps — URI versioning (/api/v1/) vs. header versioning (Accept: application/vnd.myapp.v1+json) — and then says "choose based on your needs" and moves on. That's where they all stop. I'm going to start there.

I've shipped both approaches across a dozen production APIs over the years, for clients in healthcare, e-commerce, real estate, and industrial controls. I have opinions. The short version: URI versioning is operationally simpler and I reach for it almost every time. Header versioning has one genuine advantage and a pile of hidden costs that only show up after you ship.

What You're Actually Solving

API versioning isn't a philosophical exercise. It's a contract problem. You've got clients — mobile apps, third-party integrations, your own frontend — that depend on a specific response shape. At some point you need to change that shape. Versioning is how you change it without breaking existing clients on a Tuesday morning.

The mechanism you pick affects three things: how clients call you, how you route requests internally, and how you debug things when something goes wrong at 2am. Architecture posts focus on the first one. The second and third are where the real pain lives.

URI Versioning in Laravel

This is the routes/api.php approach everyone learns first. You prefix your routes:

// routes/api.php

Route::prefix('v1')->name('v1.')->group(function () {
    Route::apiResource('orders', App\Http\Controllers\V1\OrderController::class);
});

Route::prefix('v2')->name('v2.')->group(function () {
    Route::apiResource('orders', App\Http\Controllers\V2\OrderController::class);
});

Your controllers live in App\Http\Controllers\V1\ and App\Http\Controllers\V2\. Your resources, form requests, and transformers can namespace the same way.

For most projects I'll also lean on a shared base controller and only override what changed:

// App\Http\Controllers\V2\OrderController.php

namespace App\Http\Controllers\V2;

use App\Http\Controllers\V1\OrderController as BaseOrderController;

class OrderController extends BaseOrderController
{
    public function show(Order $order): JsonResponse
    {
        // V2 adds a `fulfillment` block the old clients don't expect
        return response()->json([
            ...(new \App\Http\Resources\V1\OrderResource($order))->resolve(),
            'fulfillment' => new \App\Http\Resources\V2\FulfillmentResource($order->fulfillment),
        ]);
    }
}

It's not elegant, but it's explicit. Six months later, a junior dev can open the codebase and immediately understand that V2 orders look different from V1 orders. The version is right there in the namespace and the URL.

Header Versioning in Laravel

Header versioning means the URL stays /api/orders forever and the client communicates which version it wants via a request header — typically Accept with a vendor media type:

Accept: application/vnd.myapp.v2+json

To route on that in Laravel, you register a custom middleware or lean on a route macro. Here's a lightweight approach using middleware that rewrites the request before it hits your router:

// App\Http\Middleware\ApiVersionMiddleware.php

namespace App\Http\Middleware;

use Closure;
use Illuminate\Http\Request;

class ApiVersionMiddleware
{
    public function handle(Request $request, Closure $next): mixed
    {
        $accept = $request->header('Accept', '');

        if (str_contains($accept, 'vnd.myapp.v2')) {
            $request->attributes->set('api_version', 'v2');
        } else {
            $request->attributes->set('api_version', 'v1');
        }

        return $next($request);
    }
}

Then inside your single controller you branch:

public function show(Request $request, Order $order): JsonResponse
{
    $version = $request->attributes->get('api_version', 'v1');

    $resource = match($version) {
        'v2'    => new \App\Http\Resources\V2\OrderResource($order),
        default => new \App\Http\Resources\V1\OrderResource($order),
    };

    return response()->json($resource);
}

Alternatively you can do real route-level separation using a custom router, but it's a lot of scaffolding and most teams end up with the branching-inside-controllers pattern anyway. Which is a problem I'll get to in a minute.

Where URI Versioning Actually Hurts

Code duplication is the honest cost. When you cut V2, you either copy controllers (maintenance nightmare) or you build a careful inheritance hierarchy (complexity that bites you when V3 arrives). I've done both. Neither is free.

The other thing nobody mentions: route list bloat. Run php artisan route:list on a three-version API and it's a wall of noise. Small thing, but when you're debugging a routing issue under pressure, it matters.

Also, if you're building a public-facing API and your clients are external companies, some of them will hardcode /api/v1/ into their integration and never update it. Which is actually fine — that's the whole point of versioning. But you will be running v1 for longer than you planned. Budget for it.

Where Header Versioning Actually Hurts

This is where I have the most to say, because the problems are less obvious and they only show up in operations.

Caching breaks in non-obvious ways. Any HTTP cache — CloudFront, Nginx, Varnish, Laravel's own response cache — keys on the URL by default. Two clients hitting /api/orders/42, one wanting v1 and one wanting v2, will get each other's cached responses if your cache layer isn't configured to vary on Accept. You have to add Vary: Accept headers and make sure every cache layer in your stack respects it. I've seen this bite a client in production when they were behind CloudFront and hadn't set the cache policy to forward the Accept header. The v2 client was getting v1 responses, silently, for two hours.

// You have to remember to do this everywhere
return response()->json($resource)
    ->header('Vary', 'Accept');

curl and Postman testing gets annoying fast. With URI versioning I can paste a URL into a browser or send it in Slack. With header versioning, every test requires setting a header. This sounds minor until you're trying to walk a non-technical client through reproducing a bug or you're writing documentation that someone else has to follow.

The branching-inside-controllers problem is real. Teams always say they'll keep version logic clean. They never do. Six months in, you have if ($version === 'v2') blocks scattered across controllers, form requests, policies, and event listeners. At that point you've paid the cost of header versioning (hidden complexity, caching headaches) without getting the supposed benefit (clean URLs). URI versioning forces the separation to be explicit and structural. Header versioning lets you be lazy about it, and lazy compounds.

Logging and tracing. When I grep logs for a specific request, I can filter by URL path. /api/v1/orders vs /api/v2/orders shows up immediately. With header versioning, all orders requests look identical in the URL log. You have to make sure your logging middleware captures the version header and surfaces it, which is doable but one more thing to forget.

The One Thing Header Versioning Actually Does Better

I want to be fair here. If you're building an API where URL aesthetics genuinely matter — public developer-facing APIs, hypermedia APIs, or situations where the same resource URL needs to be stable as a canonical identifier — header versioning makes a real argument for itself. REST purists will tell you the URL should represent the resource, not the contract version, and they're not wrong philosophically.

I built an API for a healthcare client that fed data to three separate third-party EMR integrations. Those vendors had their own URL-based caching and audit logging baked in, and changing the URL structure between versions would have required coordinating re-configuration across all three systems. Header versioning was the right call there because the operational constraint was external.

But that's a specific situation. Most of the time, the URL aesthetics argument is theoretical and the caching/logging/debugging costs are real.

When I'd Reach for Each

URI versioning (/api/v1/, /api/v2/):

  • Internal APIs consumed by your own frontend or mobile apps
  • APIs with a small, controlled set of clients
  • Any time your team is smaller than ten engineers
  • When you want junior devs to be able to navigate the codebase without a guide
  • Almost always

Header versioning:

  • Public APIs where URL stability is a documented guarantee
  • When downstream clients have their own URL-based caching you can't control
  • When your team has strong discipline around keeping version logic out of controller bodies (rare)
  • When you already have a robust cache-control setup that handles Vary correctly

One more thing: whatever you pick, pick it before you ship v1, not after. Migrating between versioning strategies on a live API is miserable. I've done it once, when a client changed direction mid-project. Never again.

Closing

The architecture posts aren't wrong about header versioning being "more RESTful." They're just not the ones who have to wake up at 2am when CloudFront is serving the wrong response shape to a paying customer. URI versioning puts the version where everyone can see it — in the URL, in the namespace, in the logs — and that visibility pays dividends every time something goes sideways. Choose boring, explicit, and debuggable. Save the elegance for the parts of your system that actually need it.

Need help shipping something like this? Get in touch.