Choosing Where the Version Lives: URI Prefix vs Header vs Query
Your API is in production, a mobile client shipped eight months ago cannot be forced to update, and you need to rename author_name to author and split body into summary plus markdown. The question is not whether old clients break — it is where you put the switch so they keep working while new clients see the new shape.
Four placements are in play:
| Strategy | Shape | Pros | Cons |
|---|---|---|---|
| URI prefix | /v1/articles |
Cacheable, discoverable, obvious in logs and curl | Version in every URL; one version per URL |
| Custom header | Accept: application/vnd.ex.v2+json |
Clean URLs; version per representation | Hidden from logs and CDN keys; awkward to test by hand |
| Query param | /articles?version=2 |
Trivial to add late | Reads like a filter; clients forget to send it |
| Subdomain | v2.api.example.com |
Full separation of worlds | Duplicates deployments, certs, and monitoring |
For a public API, URI versioning is the default recommendation, and the reason is operational rather than theoretical: a CDN log line, a load balancer metric, and a curl command all show /v1/ versus /v2/ with no special tooling, and support can identify a straggler from a URL alone. Header versioning is the more textbook REST answer — the URL identifies the resource, the Accept header identifies the representation — and you pick it when one resource must serve multiple media types; you pay for it in cache configuration (the Accept header must enter the cache key) and in "which version am I on?" support load.
Your front controller makes the URI form nearly free — the version is just the first segment, the same dispatch machinery from Routing Requests in a Front Controller:
$path = '/v1/articles/42';
if (!preg_match('#^/(v\d+)/(.+)$#', $path, $m)) {
http_response_code(404);
exit;
}
[, $version, $rest] = $m; // 'v1', 'articles/42'
$routes = [
'v1' => ['#^/articles/(\d+)$#' => ['GET' => [V1\ArticleController::class, 'show']]],
'v2' => ['#^/articles/(\d+)$#' => ['GET' => [V2\ArticleController::class, 'show']]],
];
Note what the version buys you: two controllers over one domain model, not two codebases. The v1 controller translates the new model back into the old wire shape; everything below stays shared.
One pragmatic detail: at launch, an API usually serves /articles with no version at all. Pin it immediately anyway — map bare paths internally to v1, or 308-redirect /articles to /v1/articles — because retrofitting the prefix later is itself a breaking change for every client you just taught that URLs never change. Even when there is exactly one version, put it in the URL from the first production request.
The discipline that makes any of this work is not the router, it is the contract: an existing version's response never changes shape. Semantic versioning gives you the vocabulary — MAJOR for breaking, MINOR for additive, PATCH for fixes — but HTTP clients only see what you expose, so the practical policy for a /v1-style API is:
| Change | Version action |
|---|---|
| New optional response field | None (clients must ignore unknown fields) |
| New endpoint, new optional request param | MINOR; no URI bump |
| Remove/rename a field, change a type | MAJOR — new URI prefix |
| Tighten request validation | MAJOR — previously valid requests now fail |
And the corollary interviewers listen for: changing a field's shape under the same URL is not a small refactor — it is a silent break. The client does not throw an error; it renders wrong data, or stores a string where it expects a number, and the bug surfaces three layers away. Composer's constraints govern your PHP dependencies; they do nothing for HTTP consumers. The URL prefix is the only version an external client can pin.
Sunsetting /v1: Deprecation Headers, Metrics, and a Stated Date
Announcing a deprecation in a changelog is not a deprecation. Mobile apps update late or never, server integrations are maintained by people who have left the company, and a version that vanishes without warning produces outage tickets instead of migrations. HTTP lets you carry the warning in the protocol itself.
Three headers do the work, on every response from the retiring version:
// middleware — attached to every /v1 response
header('Deprecation: true'); // deprecated now
header('Sunset: Wed, 01 Apr 2027 00:00:00 GMT'); // removal is scheduled
header('Link: </v2/articles>; rel="successor-version", '
. '</docs/migration-v2>; rel="alternate"');
Deprecation states that the resource is deprecated (it may be true or the date it happened). Sunset follows RFC 8594 and carries an HTTP-date after which the endpoint goes away. The Link relation points at the replacement, so a client can find it without reading your blog. Together they let consumers act programmatically — log a warning today, surface an in-app notice thirty days out, fail fast the morning after Sunset.
Headers only work if the schedule behind them is real, so the sequence is fixed:
| Step | Action | Typical window |
|---|---|---|
| 1. Measure | Log version segment per API key; find who still calls /v1 |
2 weeks of data |
| 2. Announce | Changelog, email to affected keys, docs banner | 90 days before removal |
| 3. Warn | Deprecation / Sunset headers on all /v1 responses |
from announcement |
| 4. Migrate | Publish a field-mapping guide; both versions live in parallel | overlapping |
| 5. Remove | /v1 answers 410 Gone with a Link to /v2 |
on the stated date |
Step 1 is the one teams skip, and the one that prevents the outage: "who calls /v1?" is answerable from access logs only if you recorded the version segment alongside the key. The honest number is always uncomfortable — there is an integration nobody remembered, and without data you will either cut it off early or keep /v1 alive forever.
Step 5 needs care in the response itself. 404 says "we never had this"; 410 Gone says "this existed and will not come back", which is exactly the semantic you owe a straggler — and a Link header turns the dead end into a signpost:
http_response_code(410);
header('Content-Type: application/json; charset=utf-8');
header('Link: </v2/articles>; rel="successor-version"');
echo json_encode(['error' => [
'code' => 'version_retired',
'message' => '/v1 was retired on 2027-04-01; migrate to /v2',
]]);
exit;
The same lifecycle applies one level down, to fields inside a live version. Deprecate a field in the payload docs and headers first, keep returning it for a full MINOR cycle while consumers migrate, then drop it at the next MAJOR. A Warning-style note in the response or a _deprecated marker in the schema tells client developers what is coming without changing any type.
None of this requires a new framework — the headers are four lines in middleware, and the removal is a route-table entry. What it requires is a published policy with numbers in it: how long a version lives after its successor ships, how much notice a breaking field change gets, and where the schedule is written down. An API that states its timeline in dates earns integrator trust; one that says "we will deprecate stuff eventually" gets pinned to version one forever. The remaining judgment call — which changes deserve that ceremony at all — is triaged in the next chapter.
Which Changes Are Breaking? A Triage Table You Can Test
Two engineers look at the same diff and disagree: "we only renamed a field" versus "the field is gone". The disagreement is expensive, so the fix is to replace intuition with a table — and then replace the table's enforcement with tests.
| Change | Breaking? | Why the client cares |
|---|---|---|
| Remove a response field | Yes | Reads as undefined/null; code rendering it breaks |
| Add an optional response field | No | Tolerant readers ignore what they don't know |
| Rename a field | Yes | Equivalent to removal plus addition — twice the breakage |
Change a type ("42" → 42) |
Yes | Parsers and strict DTOs throw |
Add an enum value (status: "archived") |
Often | Exhaustive switch statements mishandle it |
| Loosen request validation | No | Every old request is still valid |
| Tighten request validation | Yes | Previously valid requests now earn 422 |
Change a success status (200 → 204) |
Yes | The body disappears from under the client |
| Reorder a JSON array | Yes | Positional data changed meaning |
Move pagination from page to cursor |
Yes | Request shape and response meta both changed |
Two principles do most of the sorting. Tolerant readers: both sides ignore properties they do not recognize — the client skips new fields, the server skips unknown request keys unless you deliberately run strict mode. Tolerance is what makes "add an optional field" safe, and it means a client that fatals on unknown keys becomes your problem the moment you add anything. It also defines the limit: tolerance protects against additions, never against removals or type changes.
The corollary is the one rule this whole topic hangs on: a breaking change gets a new URI, always. The mechanism for keeping the old one alive is a presenter — new domain model, old wire shape:
final class ArticleV1Presenter
{
public function show(Article $a): array
{
return [
'id' => (string) $a->id,
'author_name' => $a->author->name, // v1 name survives
'body' => $a->markdown, // v2 renamed body → markdown
'created' => $a->createdAt->format('Y-m-d H:i:s'),
];
}
}
That class is the entire compatibility layer: v2 controllers return the new shape, v1 controllers run their output through the presenter, and both read the same Article record. Delete the file when /v1 reaches its Sunset date and nothing else changes — the boundary is exactly one class, which is what makes the eventual removal cheap instead of a rewire.
Arguing over the table in code review is itself the symptom. The truth should live in contract tests: one snapshot per version asserting exact field names, types, and status codes of every endpoint, plus a request-side fixture proving old payloads still validate. A rename then fails CI with a diff instead of failing a customer with a rendering bug — and consumer-side tolerance should be tested too: feed your client code a response containing an unknown field and assert it survives.
Those tests are ordinary assertions with an unusual discipline: they exist to prove that nothing changed. Writing them is the immediate next step, and the natural continuation of this roadmap is Configuring PHPUnit. The router serving both versions in parallel came from Routing Requests in a Front Controller, whose method table and 404/405 split stay identical across versions — only the controller behind the pattern changes.
Quiz
1. You rename author_name to author and remove body from an endpoint that shipped to mobile clients months ago. What is the correct release action?
2. Which of these changes does NOT break existing clients?
3. What does the Sunset header tell an integrator, and what value goes in it?
4. After /v1 is retired, a straggler integration calls it anyway. Which status best communicates the situation?
Flashcards
Question
Compare URI versioning (/v1) with Accept-header versioning in two sentences each.
Click to reveal answer
Answer
URI: the version shows in every log line, curl command, and CDN entry — cacheable and support-friendly, but the URL becomes the contract and you can't vary media types independently. Header (Accept: application/vnd.api.v2+json): clean URLs and per-representation versioning, but the version hides from logs, must enter the cache key, and is painful to test by hand.
Question
What makes a response change 'breaking', and what is the safe alternative?
Click to reveal answer
Answer
Removing or renaming a field, changing its type, tightening validation, changing a status code, or reordering positional arrays — anything existing client code or payloads no longer match. The safe alternative: ship it under a new major version prefix while the old version keeps returning the old shape from a presenter class over the shared domain model.
Question
Name the headers that carry a deprecation warning and what each contains.
Click to reveal answer
Answer
Deprecation: true or the deprecation date. Sunset: an RFC 8594 HTTP-date when the endpoint stops working. Link with rel="successor-version" (plus rel="alternate" for migration docs). Sent on every response from the retiring version so clients can warn users, log, and fail after the date — not just in a changelog nobody reads.
Question
Why measure usage before announcing a sunset, and what status marks the end?
Click to reveal answer
Answer
Access logs keyed by version segment reveal integrations nobody remembers — without that data you either cut someone off early or never retire anything. After the stated date the old path answers 410 Gone with a Link to the successor: 410 says 'existed, permanently retired', unlike 404's 'never existed'.
Question
What is a tolerant reader, and where does tolerance stop?
Click to reveal answer
Answer
A client (or server) that ignores JSON properties it doesn't recognize instead of failing. It makes additive changes safe: new fields, new endpoints, new optional parameters. It cannot protect against removals, renames, type changes, or tightened validation — those still require a major version bump.
Revision Notes
Key Takeaways
- 1. Put the version in the URL from the first production request — it is the only version an external client can pin, log, and cache
- 2. An existing version's response is a contract: additive fields need no bump, breaking changes get a new major prefix served by a presenter over the shared model
- 3. Deprecate in protocol, not prose: Deprecation + Sunset (RFC 8594) + Link rel="successor-version" on every response from the retiring version
- 4. Measure, announce with dates, warn, migrate in parallel, then remove with 410 Gone plus a successor link — never a silent 404
- 5. Tolerant readers make additions safe and never make removals safe; enforce the triage table with per-version contract tests in CI
Interview Tips
- • "URI or header versioning?" — choose URI for public APIs and give operational reasons (logs, curl, CDN keys), acknowledge header versioning's REST purity and its cache-key/support costs, and name the query-param and subdomain variants with their drawbacks
- • "What counts as a breaking API change?" — walk the table: removals, renames, type changes, tightened validation, and status changes are breaking; optional additions are not — then state clearly that tolerance protects additions only
- • "How do you deprecate an endpoint responsibly?" — measure callers by version key, publish dates, send Deprecation/Sunset/Link headers on every response, run versions in parallel through the window, then 410 Gone with the successor link
- • "How do you keep v1 working while v2 ships?" — one domain model, a v1 presenter mapping to the old wire shape, and per-version contract snapshot tests in CI that fail on any field rename; deleting that presenter file is the entire retirement
Cheat Sheet
Versioning API Endpoints
Where the version lives:
| Strategy | Shape | Wins | Costs |
|---|---|---|---|
| URI prefix | /v1/articles |
logs, curl, CDN cache | URL is the contract |
| Accept header | application/vnd.ex.v2+json |
clean URLs | hidden in logs; must enter cache key |
| Query param | ?version=2 |
easy to add late | looks like a filter; easy to omit |
| Subdomain | v2.api.example.com |
full separation | duplicated infra |
Default for public APIs: URI prefix, set from the first production request (retrofitting it later is itself breaking).
Breaking-change triage:
| Change | Breaking? |
|---|---|
| Remove / rename a field | Yes |
| Change a field's type | Yes |
| Tighten request validation | Yes |
| Change a success status | Yes |
| Add an optional field | No (tolerant readers) |
| Loosen validation, new endpoint | No (MINOR) |
Tolerant readers protect additions only — never removals or type changes.
Sunset protocol (on every retiring-version response):
header('Deprecation: true'); // or the deprecation date
header('Sunset: Wed, 01 Apr 2027 00:00:00 GMT'); // RFC 8594 removal date
header('Link: </v2/articles>; rel="successor-version"');
Lifecycle: measure callers by version key → announce (90d) → warn (headers) → migrate (parallel versions) → remove with 410 Gone + successor Link (not 404 — 410 says "existed, permanently retired").
Keep v1 alive: one domain model, one presenter per version:
return ['author_name' => $a->author->name, // v1 wire shape
'body' => $a->markdown]; // v2 calls it markdown
Delete the presenter file to retire the version. Enforce all of it with per-version contract snapshot tests in CI.