Skip to content
advanced Phase 11 · HTTP and REST APIs

Versioning API Endpoints

Evolve responses without breaking existing clients.

45m
0 problems
Topic Progress 0%

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?

Question 1 options

2. Which of these changes does NOT break existing clients?

Question 2 options

3. What does the Sunset header tell an integrator, and what value goes in it?

Question 3 options

4. After /v1 is retired, a straggler integration calls it anyway. Which status best communicates the situation?

Question 4 options

Flashcards

Question

Compare URI versioning (/v1) with Accept-header versioning in two sentences each.

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?

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.

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?

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?

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.

Continue Learning