Skip to content

How-to: API Versioning

FT reference: FT346 (NENE2-FT/versionlog) — URL path versioning with /v1/ and /v2/ namespaces, deprecated V1 carrying Deprecation/Sunset/Link headers, V2 with enriched response shape, shared underlying storage, 16 tests PASS.

This guide shows how to implement URL path versioning: run two API versions side by side with different response shapes, mark the old version as deprecated with HTTP headers, and share a single database across versions.

Version Strategy

VersionStatusPrefixList wrapper
V1Deprecated/v1/{"notes": [...]}
V2Current/v2/{"data": [...], "meta": {...}}

Both versions share the same database tables. V1 clients can continue using their existing integration while the deprecation headers signal a migration deadline.

Endpoints

MethodV1 PathV2 PathDescription
POST/v1/notes/v2/notesCreate note
GET/v1/notes/v2/notesList notes
GET/v1/notes/{id}/v2/notes/{id}Get single note

V1 Response Shape

php
// POST /v1/notes
{"title": "Hello", "content": "World"}
 201
{
  "id": 1,
  "title": "Hello",
  "content": "World",    // ← field name: "content"
  "created_at": "..."
  // No "body", no "tags", no "updated_at"
}

// GET /v1/notes
 200
{
  "notes": [              // ← wrapper key: "notes"
    {"id": 1, "title": "Hello", "content": "World", ...}
  ]
}

V2 Response Shape

php
// POST /v2/notes
{"title": "Hello", "body": "World", "tags": ["php", "api"]}
 201
{
  "data": {               // ← envelope key: "data"
    "id": 2,
    "title": "Hello",
    "body": "World",      // ← field name: "body"
    "tags": ["php", "api"],  // ← tags added
    "updated_at": "...",     // ← updated_at added
    "created_at": "..."
  }
}

// GET /v2/notes
 200
{
  "data": [...],          // ← list wrapper: "data"
  "meta": {               // ← meta section
    "limit": 20,
    "offset": 0
  }
}

V1 Deprecation Headers

Every V1 response carries three headers informing clients to migrate:

Deprecation: true
Sunset: Sat, 01 Jan 2027 00:00:00 GMT
Link: </v2/notes>; rel="successor-version"
php
// Every V1 endpoint adds:
return $response
    ->withHeader('Deprecation', 'true')
    ->withHeader('Sunset', 'Sat, 01 Jan 2027 00:00:00 GMT')
    ->withHeader('Link', '</v2/notes>; rel="successor-version"');

V2 responses carry none of these headers.

php
// V1 GET /v1/notes headers:
Deprecation: true
Sunset: Sat, 01 Jan 2027 00:00:00 GMT
Link: </v2/notes>; rel="successor-version"

// V2 GET /v2/notes headers:
// (no Deprecation, Sunset, or Link)

Shared Storage — Cross-Version Access

Both versions share the same notes table. A note created via V1 is readable from V2 (and vice versa):

php
// Create via V1
POST /v1/notes  {"title": "Cross-version", "content": "Shared body"}
 201  {"id": 5, "title": "Cross-version", "content": "Shared body", ...}

// Read via V2 — same record, V2 shape
GET /v2/notes/5
 200
{
  "data": {
    "id": 5,
    "title": "Cross-version",
    "body": "Shared body",    // V2 calls it "body", not "content"
    "tags": [],
    "updated_at": "...",
    "created_at": "..."
  }
}

V1 clients never see tags (not in the V1 response shape), even if the note has tags from a V2 write.

Schema

sql
CREATE TABLE notes (
    id         INTEGER PRIMARY KEY AUTOINCREMENT,
    title      TEXT    NOT NULL,
    body       TEXT    NOT NULL DEFAULT '',
    tags       TEXT    NOT NULL DEFAULT '[]',  -- JSON array
    created_at TEXT    NOT NULL,
    updated_at TEXT    NOT NULL
);

The underlying column is body. V1 maps it to content in the response transformer.

Implementation — Response Transformers

php
// V1 transformer — maps "body" column → "content" field, hides tags/updated_at
final class V1NoteTransformer
{
    /** @param array<string, mixed> $row */
    public function transform(array $row): array
    {
        return [
            'id'         => $row['id'],
            'title'      => $row['title'],
            'content'    => $row['body'],   // field rename
            'created_at' => $row['created_at'],
            // No "body", "tags", "updated_at"
        ];
    }
}

// V2 transformer — full row, wrapped in "data"
final class V2NoteTransformer
{
    /** @param array<string, mixed> $row */
    public function transform(array $row): array
    {
        return [
            'id'         => $row['id'],
            'title'      => $row['title'],
            'body'       => $row['body'],
            'tags'       => json_decode($row['tags'], true),
            'updated_at' => $row['updated_at'],
            'created_at' => $row['created_at'],
        ];
    }
}

Route Registration

php
// V1Registrar::register()
$router->get('/v1/notes',       [V1ListHandler::class, 'handle']);
$router->post('/v1/notes',      [V1CreateHandler::class, 'handle']);
$router->get('/v1/notes/{id}',  [V1GetHandler::class, 'handle']);

// V2Registrar::register()
$router->get('/v2/notes',       [V2ListHandler::class, 'handle']);
$router->post('/v2/notes',      [V2CreateHandler::class, 'handle']);
$router->get('/v2/notes/{id}',  [V2GetHandler::class, 'handle']);

Both registrars are passed to RuntimeApplicationFactory — routes from both are registered in the same router.

Unknown Version → 404

php
GET /v3/notes
 404

There is no V3 route; the router returns 404. No "version not supported" error type is needed — 404 is sufficient.

Validation

php
POST /v1/notes  {"content": "no title"}
 422  // title is required

POST /v2/notes  {"body": "no title"}
 422  // title is required

Both versions require title. V1 accepts content as the body field; V2 accepts body.


What NOT to do

Anti-patternRisk
Different DB tables per versionCross-version reads break; data migrates poorly when versions share no state
Return Deprecation: true on V2Clients cannot distinguish which version is current
No Link header with successorDeprecated clients don't know where to migrate to
Rename DB column bodycontent for V1All V2 code must change; use response transformer to rename, not schema
Hard-code Sunset date in testsTests fail after the sunset date; use a future constant or config value
Expose V1 tags in responseV1 clients receive a field they don't understand; shape contracts break silently

Released under the MIT License.