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 body → content 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.