06 · API Versioning & Documentation¶
An API with real clients can't just change shape whenever the backend needs to — a mobile app on last year's release still expects last year's response format. Versioning lets old and new response shapes coexist, and generated documentation keeps the description of an API from silently drifting away from what the code actually does.
Content-negotiated versioning¶
There's more than one way to version an API (/v1/users in the URL,
?version=2 in the query string), but header-based negotiation via the
Accept header keeps URLs stable while still letting a client pin an exact
version:
<?php
declare(strict_types=1);
interface ApiVersion {
public function transform(array $data): array;
}
final class V1Response implements ApiVersion {
public function transform(array $data): array {
return ['id' => $data['id'], 'name' => $data['full_name']];
}
}
final class V2Response implements ApiVersion {
public function transform(array $data): array {
return [
'id' => $data['id'],
'name' => ['first' => explode(' ', $data['full_name'])[0], 'full' => $data['full_name']],
'meta' => ['version' => 2],
];
}
}
Each version's transform() maps the same internal data ($data) to a
different public shape. Nothing about how the user is stored or fetched
changes between versions — only how it's presented, which is exactly the
separation that lets v1 clients keep working while v2 is developed.
<?php
final class Router {
private array $versions = [];
public function register(string $version, ApiVersion $handler): void {
$this->versions[$version] = $handler;
}
public function handle(string $acceptHeader, array $data): array {
// Accept: application/vnd.myapi.v2+json
if (!preg_match('/vnd\.myapi\.(v\d+)\+json/', $acceptHeader, $m)) {
throw new InvalidArgumentException("Unrecognized Accept header: $acceptHeader");
}
$version = $m[1];
if (!isset($this->versions[$version])) {
throw new RuntimeException("Unsupported API version: $version");
}
return $this->versions[$version]->transform($data);
}
}
$router = new Router();
$router->register('v1', new V1Response());
$router->register('v2', new V2Response());
$user = ['id' => 42, 'full_name' => 'Ada Lovelace'];
echo json_encode($router->handle('application/vnd.myapi.v1+json', $user), JSON_PRETTY_PRINT) . "\n";
echo json_encode($router->handle('application/vnd.myapi.v2+json', $user), JSON_PRETTY_PRINT) . "\n";
try {
$router->handle('application/vnd.myapi.v9+json', $user);
} catch (RuntimeException $e) {
echo "Caught: " . $e->getMessage() . "\n";
}
{
"id": 42,
"name": "Ada Lovelace"
}
{
"id": 42,
"name": {
"first": "Ada",
"full": "Ada Lovelace"
},
"meta": {
"version": 2
}
}
Caught: Unsupported API version: v9
A vendor-specific media type (application/vnd.myapi.v2+json, rather than a
raw application/json) makes version negotiation explicit and cacheable at
the HTTP layer — proxies and CDNs can key on it, unlike a custom header that
generic HTTP infrastructure doesn't know to look at.
Generating docs from the code, not beside it¶
Hand-written API docs (a wiki page, a separate Markdown file) rot the moment
someone changes an endpoint and forgets to update the doc. PHP 8's
attributes let documentation live as metadata directly on the code that
implements it, so a doc generator can read it back via Reflection — the
same mechanism Level 3's DI module
used to inspect constructors.
<?php
declare(strict_types=1);
#[Attribute]
final class RouteDoc {
public function __construct(
public string $method,
public string $path,
public string $summary,
) {}
}
final class UserController {
#[RouteDoc('GET', '/users/{id}', 'Fetch a single user by id')]
public function show(int $id): array { return ['id' => $id]; }
#[RouteDoc('POST', '/users', 'Create a new user')]
public function store(array $data): array { return $data; }
}
<?php
function generateDocs(string $class): array {
$reflection = new ReflectionClass($class);
$docs = [];
foreach ($reflection->getMethods() as $method) {
foreach ($method->getAttributes(RouteDoc::class) as $attr) {
$route = $attr->newInstance();
$docs[] = [
'method' => $route->method,
'path' => $route->path,
'summary' => $route->summary,
'handler' => $method->getName(),
];
}
}
return $docs;
}
foreach (generateDocs(UserController::class) as $entry) {
printf("%-5s %-15s %-30s -> %s()\n", $entry['method'], $entry['path'], $entry['summary'], $entry['handler']);
}
$attr->newInstance() actually constructs a real RouteDoc object from the
attribute's declared arguments — attributes aren't just string metadata,
they're instantiable classes, so the doc generator gets typed, validated
data rather than parsing a docblock comment with regex. This is the same
mechanism behind real tools like zircote/swagger-php, which walk attributes
like this one to emit a full OpenAPI JSON/YAML spec.
PHP traps¶
Attribute arguments are evaluated once, at newInstance() time, not at
class-load time. Declaring #[RouteDoc('GET', '/users/{id}', SOME_CONST)]
works fine because PHP resolves constants when it builds the instance — but
attributes cannot run arbitrary expressions or call functions in their
argument list, only literals, constants, and (as of PHP 8.1) enum cases.
Trying #[RouteDoc(strtoupper('get'), ...)] is a compile error.
A version transformer that mutates the original data breaks the other
version's transformer. If V1Response::transform() did
$data['name'] = $data['full_name']; unset($data['full_name']); and $data
were passed by reference elsewhere, a second call for V2Response on the
same underlying data would find full_name already gone. Keeping
transform() pure — read $data, return a new array, never mutate the
input — is what makes registering multiple version handlers against the same
source data safe.
Forgetting to update the Accept regex when adding v10+ silently breaks
matching. (v\d+) handles v1 through v99 correctly, but a version
scheme that switches to letters or dates (v2024-01) needs the pattern
updated — an easy thing to miss since the code keeps "working" for existing
versions and only the new one 404s.
Versioning & docs cheat sheet¶
| Concept | Mechanism | Example |
|---|---|---|
| Header-based versioning | Accept: application/vnd.*.vN+json |
V1Response vs. V2Response |
| Version isolation | Each version has its own pure transform() |
Same $data, different shape out |
| Unsupported version | Fail with a clear error, not a silent default | RuntimeException in handle() |
| Docs as metadata | PHP 8 attributes (#[RouteDoc(...)]) |
Read via Reflection |
| Doc generation | getAttributes() + newInstance() |
generateDocs() |
| Real-world equivalent | zircote/swagger-php, OpenAPI spec generation |
Attributes -> JSON/YAML spec |
How It Actually Works¶
Content-negotiated versioning works by branching on the same $_SERVER['HTTP_ACCEPT'] (or a custom header) string inspection used for content negotiation elsewhere — there's no special "version" concept in HTTP or PHP, just a router reading a header value and dispatching to different handler code paths based on a string match, exactly like any other conditional routing decision. Generating docs from code rather than beside it typically means using PHP's reflection API (or parsing PHPDoc-style comment blocks attached to methods, which the engine preserves as retrievable metadata via ReflectionMethod::getDocComment()) to build an OpenAPI/Swagger spec programmatically at build time or on-demand — because this walks the actual compiled class structure rather than a hand-maintained separate document, the generated docs can never drift out of sync with real method signatures the way hand-written docs inevitably do, since a signature change is reflected the next time the generator runs its reflection pass, with no separate synchronization step to forget.
Exercise¶
Add a V3Response that includes a links field
(['self' => "/users/{$data['id']}"]) alongside the V2Response shape, and
register it under 'v3'. Then extend RouteDoc with an optional
deprecated: bool = false constructor parameter, mark UserController::show()
as #[RouteDoc('GET', '/users/{id}', 'Fetch a single user by id',
deprecated: true)], and update generateDocs() to print [DEPRECATED]
before any entry where deprecated is true.