Skip to content
All posts

Stop Putting Version Numbers in Your API URLs

April 30, 2026·Read on Medium·

You won’t delete /v1 because someone is still using it. That’s the trap.

Here is a situation that plays out in nearly every team that ships an API and calls it “versioned”: it’s year three of the project, and you have /api/v1, /api/v2, and a freshly minted /api/v3. Version 1 was supposed to be deprecated eighteen months ago. Version 2 broke something for the mobile team so they never fully migrated. Version 3 is what you actually want clients to use, but the documentation for v2 is still what shows up on Google.

You are now maintaining three separate routing trees, three sets of controller classes (or one with enough conditional logic to make a grown engineer cry), and three implicit contracts that you never formally documented. Someone on the team still refers to UserController.php and means one of four files depending on which folder they're in.

This is the hidden tax of URI versioning, and almost nobody talks about it until the codebase has already become a maintenance nightmare.

Why /v1 Felt Like the Right Decision

URL versioning is the most popular strategy because it is the most visible one. The version is right there in the address bar. Any API client, log parser, or proxy can see it without reading HTTP headers. Tools like Postman show it immediately. It feels explicit.

The official Laravel documentation has examples of it. Stack Overflow answers recommend it. ByteByteGo diagrams include it. If everyone does it, it must be right.

The problem is that “visible” and “correct” are not the same thing. URL versioning is popular because it is easy to implement, not because it is the right long-term approach to API evolution. And the industry has been confusing those two things for years.

The Actual Cost of URL Versioning

When you version by URL, you are making a promise to your clients: this endpoint will always behave exactly this way, forever. That sounds responsible. In practice, it means every meaningful change spawns a new version, which spawns a new codebase branch, which spawns a new maintenance burden.

Code duplication compounds fast. Even if you are clever about it and share underlying service classes, you end up with parallel route files, parallel controller namespaces and parallel request/response transformers. Six months in, a security patch to your authentication middleware needs to be applied to every version independently. You miss one. Congratulations, you now have a CVE.

Clients anchor to the lowest version they can get away with. This is universal. Mobile apps in particular move slowly. An iOS client that shipped targeting /v2 will not be updated until the product team forces a minimum app version. That can take a year. Maybe two. Meanwhile your /v2 infrastructure is running in production solely so 3% of your user base on an old app version does not get a 404.

The “deprecated” label is a lie. Every team I have worked with puts up a deprecation notice on v1, watches the traffic numbers slowly drop to single-digit percentages, and then leaves it running indefinitely because the risk of breaking someone outweighs the cost of keeping it alive. The version is never actually deleted. It just becomes undocumented legacy infrastructure.

The real problem is not the version number in the URL. The real problem is the assumption underneath it: that every API change is a breaking change. If you fix that assumption, you barely need versioning at all.

Additive API Design: The Boring Solution That Actually Works

Most API changes do not need to be breaking. The principle is simple: only add, never remove or change the meaning of existing fields. This is how Stripe, Twilio and GitHub have kept their APIs functional for clients built years ago.

A new field appears in the response? Existing clients ignore it. A new optional request parameter? Existing clients do not send it, which is fine. A new endpoint? Nobody has to use it.

This covers maybe 80% of the changes a product API needs to make over its lifetime. Feature additions, new query parameters, optional fields, richer error bodies, new resource endpoints: all of these can ship without touching a version number.

The cases that actually require a breaking change are narrower than most teams think: renaming a required field, changing the data type of an existing field, making an optional field required, removing an endpoint entirely. These are the moments that warrant a versioning conversation. Not every sprint.

When you approach API design with additive-first thinking, you stop treating every refactor as a breaking change. Clients stay on the same URL and keep working. Your codebase does not fork.

When You Do Need to Break Things

Sometimes a breaking change is unavoidable. A field name was a mistake from day one, an authentication scheme has a security flaw, a response structure needs a structural overhaul. In those cases you need a proper strategy.

Header-based versioning is the technically cleaner answer. Instead of encoding the version in the URL, the client sends it as a request header:

GET /api/users HTTP/1.1
Host: api.example.com
Accept-Version: 2

Or using content negotiation with the Accept header:

GET /api/users HTTP/1.1
Host: api.example.com
Accept: application/vnd.yourapi.v2+json

The URL stays stable. The resource is still /api/users. The contract for that specific client is negotiated via the header. Proxies, load balancers and CDN configurations do not need to know about your versioning scheme. Your documentation URL does not need to change.

The common objection is that headers are less visible than paths. That is true. But visibility is a developer experience concern, not an architectural one. Your API gateway logs can capture the version header. Your observability stack can include it. The slight inconvenience of not seeing v2 in the URL is not worth the long-term cost of parallel URL trees.

Telling Clients When to Migrate: RFC 9745 and RFC 8594

Additive-first design and header versioning solve the structural problems, but they do not solve communication. Clients need to know when a version or specific endpoint is going away.

The HTTP spec has answers for this. Both are standards track and widely supported.

RFC 9745 (published March 2025 by IETF) defines the Deprecation response header. When a resource or version is no longer the preferred path, your server starts sending:

Deprecation: @1735689600
Link: <https://docs.example.com/migration-v3>; rel="deprecation"

The timestamp is Unix epoch. The link points to your migration guide. Every response from that endpoint carries this signal. Any client that is logging response headers will see it. Any monitoring tool that watches headers will alert on it.

RFC 8594 defines the Sunset header, which is the harder deadline: the date after which the endpoint will stop responding.

Sunset: Thu, 01 Jan 2027 00:00:00 GMT

Used together, these two headers give clients a deprecation notice and a firm removal date, baked directly into the HTTP response. No separate email announcement, no changelog that nobody reads. The client’s own logs become the migration reminder.

This is how you actually communicate breaking changes without URL versions piling up.

Implementing This in Laravel

Laravel makes route-level middleware straightforward. You can attach deprecation headers to any route group without touching the underlying controllers.

First, create a middleware:

<?php

namespace App\Http\Middleware;
use Closure;
use Illuminate\Http\Request;
class DeprecationWarning
{
public function __construct(
private string $deprecationTimestamp,
private string $sunsetDate,
private string $docsUrl
)
{}
public function handle(Request $request, Closure $next): mixed
{
$response = $next($request);
$response->headers->set('Deprecation', '@' . $this->deprecationTimestamp);
$response->headers->set('Sunset', $this->sunsetDate);
$response->headers->set(
'Link',
'<' . $this->docsUrl . '>; rel="deprecation"'
);
return $response;
}
}

Then apply it to the routes that need to go away, not to the entire API:

// routes/api.php

Route::middleware(['auth:sanctum'])
->group(function () {
// Current routes (no deprecation header)
Route::get('/users', [UserController::class, 'index']);
Route::get('/users/{id}', [UserController::class, 'show']);
// Legacy endpoint being phased out
Route::middleware([
new DeprecationWarning(
deprecationTimestamp: '1735689600',
sunsetDate: 'Thu, 01 Jan 2027 00:00:00 GMT',
docsUrl: 'https://docs.example.com/api/migration#users-v2'
),
])->group(function () {
Route::get('/legacy/users', [LegacyUserController::class, 'index']);
});
});

No /v1 prefix. No parallel controller tree. The old endpoint keeps working and starts broadcasting its own end-of-life. You can monitor who is still hitting it from your access logs and reach out directly if needed.

For clients consuming this API, SDK authors and frontend teams can write a simple HTTP interceptor that logs or alerts when it sees a Deprecation header in any response. One implementation, works for every endpoint, no manual changelog monitoring needed.

The Broader Principle

URI versioning solves a short-term communication problem (clients need to know what they are getting) by creating a long-term infrastructure problem (you now maintain multiple parallel systems indefinitely).

Additive API design eliminates most of the need for versioning. When additive is not enough, header versioning keeps your URL structure stable. When something has to go away, Deprecation and Sunset headers (RFC 9745 and RFC 8594) handle client communication at the protocol level without email blasts or changelog folklore.

The engineers who built these RFC standards were not being theoretical. They were solving the exact problem you are living with right now: how do you tell clients something is going away without breaking them by surprise, and without leaving dead infrastructure running for years because you are afraid to pull the plug?

A Practical Starting Point

If you have an existing API with URL versions, you do not need to blow it up. Start with two things:

Add Deprecation and Sunset headers to your oldest version's routes today. This starts the clock on official deprecation without any migration work on your end. Watch your access logs. Track which clients are still hitting the deprecated routes. Set a real sunset date, not a wishful one, and communicate it through the headers.

For any new API surface you build, default to additive design and drop the /v1 prefix. Start with stable URLs and use versioning only when you genuinely have a breaking change that cannot be resolved through additive evolution.

The goal is an API that does not require a parallel codebase every time the product changes direction. That is not an unusual standard. It is just the one most teams skip because URL versioning looks simpler on day one.

Day three hundred is where you pay for it.

If your /v1 has been "deprecated" for more than six months and you have not set a Sunset date, you have not deprecated it. You have just added a warning label to something you are still maintaining.

Found this helpful?

If this article saved you time or solved a problem, consider supporting — it helps keep the writing going.

Originally published on Medium.

View on Medium
Stop Putting Version Numbers in Your API URLs — Hafiq Iqmal — Hafiq Iqmal