Appearance
REST API Versioning
Flow ID: SY-31 | Module(s): application/core, application/controllers, src/Rest/Versioning, src/Rest/Middleware, job/GenerateOpenApiJson, public/api-docs.html | Complexity: Medium Last Updated: 2026-04-30
Business Context
The REST API versioning system provides a URL-based major-version namespace (/rest/v{N}/...) that allows the platform to introduce breaking API changes without disrupting existing integrations. A version lifecycle state machine (active → deprecated → obsolete) signals transition windows to API consumers via standardised response headers (RFC 9745 Deprecation, RFC 8594 Sunset) before hard-retiring an old version.
Versioning is implemented as transparent router middleware: the CI3 router override strips the v{N} segment from the URI before route matching, so the 1685-line rest_routes.php requires no modification when a new version is introduced. The captured version number flows through the dispatch chain, where VersionResolver maps it to a VersionResult DTO that drives header injection, error responses, and — when a breaking change warrants it — a per-version controller override swap.
Documentation for all active (and deprecated) API versions is generated automatically from OpenAPI annotations and published through a version-aware Scalar UI at public/api-docs.html.
API Reference
URL Patterns
There are no new REST endpoints. The versioning layer is transparent infrastructure that applies to every existing REST endpoint.
| URL Pattern | Example | Behaviour |
|---|---|---|
/rest/{resource} | /rest/product/product | Resolves to the configured latest major version |
/rest/v{N}/{resource} | /rest/v1/product/product | Resolves to explicit version N |
/{locale}/rest/v{N}/{resource} | /en/rest/v2/product/product | Locale-prefixed with explicit version N |
/rest/v{unknown}/{resource} | /rest/v99/product/product | 400 Bad Request — "Invalid API version" |
/rest/v{obsolete}/{resource} | (when a version becomes obsolete) | 410 Gone — with deprecation pointer headers |
Response Headers
All successful REST responses include at minimum:
| Header | Value | Condition |
|---|---|---|
Api-Version | {N} | Always |
Deprecation | IMF-fixdate | Deprecated or obsolete versions (RFC 9745) |
Sunset | IMF-fixdate | When sunset date is configured (RFC 8594) |
Link | </rest/v{latest}/>; rel="successor-version" | When version != latest |
Header generation is implemented in src/Rest/Versioning/VersionResult.php:43-60.
Code Flow
HTTP Request: /rest/v1/product/product
|
v
MY_Router::_parse_routes() [application/core/MY_Router.php:31-37]
|-- regex matches /rest/v{N}/... or /{locale}/rest/v{N}/...
|-- captures $apiVersion = 1
|-- rewrites URI to /rest/product/product (strips version segment)
|-- continues standard route matching against rest_routes.php (1685 lines, unmodified)
|
v
RouterDispatcher::dispatch() [application/controllers/RouterDispatcher.php:22-60]
|-- reads $this->router->apiVersion [line 23]
|-- calls VersionResolver::resolve($apiVersion) [line 24]
|
|-- if null result (unknown version)
| |-- sendError(400, "Invalid API version") [lines 26-33]
|
|-- if result->isObsolete()
| |-- set Deprecation + Sunset + Link headers [lines 35-50]
| |-- sendError(410, "API version obsolete", pointer to latest)
|
|-- if controller override exists for this version
| |-- swap $class = overrideMap[$class] [lines 53-55]
|
|-- set Api-Version header (+ deprecation headers if deprecated)
| [lines 58-60]
|-- di()->get($class) — instantiate controller [line 69]
|-- pass $apiVersion into RequestContext [lines 94-100]
|-- run middleware pipeline (Authentication, Authorization,
| ResourceContext, RelationFilter, Audit)
|-- dispatch to controller action
|
v
Response returned with version headersVersion Resolution Detail
src/Rest/Versioning/VersionResolver::resolve() at src/Rest/Versioning/VersionResolver.php:35-56:
- If
$requestedisnull(unversioned URL) → resolve to$config['latest'] - Look up
$config['versions'][$requested] - If not found → return
null(triggers 400 inRouterDispatcher) - Build and return a
VersionResultDTO with status, override map, and deprecation/sunset dates
Controller Override Mechanism
When a breaking change introduces a new version, the old version's controller is preserved as an override class. The config map controller_overrides[N][BaseController::class] = V1OverrideController::class causes RouterDispatcher to swap $class before di()->get($class) is called (RouterDispatcher.php:53-55). This swap happens pre-instantiation, so no controller code is aware of it.
Override controllers that extend the base controller automatically inherit the base controller's RBAC policy via the PolicyResolver parent-class fallback (src/Rest/Middleware/PolicyResolver.php:27-29).
Data Model
No database tables are involved. This flow operates entirely at the routing, service, and configuration layers. Version definitions are stored in application/config/rest_api_versions.php. No migrations exist for this feature.
Domain Layer
Modern Domain (src/Rest/Versioning/)
| File | Lines | Responsibility |
|---|---|---|
src/Rest/Versioning/VersionResolver.php | 106 | DI-registered service; loads and validates config, resolves requested version to a VersionResult, exposes getActiveVersions() and getLatestVersion() |
src/Rest/Versioning/VersionResult.php | 61 | Immutable DTO with readonly constructor properties; isActive(), isDeprecated(), isObsolete() predicates; getHeaders() generating RFC-compliant response headers |
VersionResolver methods:
| Method | Lines | Purpose |
|---|---|---|
resolve(?int $requested) | 35-56 | Core resolution; null input → latest; unknown → null; found → VersionResult |
getActiveVersions() | 63-69 | Returns config versions excluding obsolete; used by OpenAPI generation |
getLatestVersion() | 71-74 | Returns $config['latest'] integer |
getConfig() | 79-83 | Returns full config array |
VersionResult constructor properties (src/Rest/Versioning/VersionResult.php:9-18):
version (int), status (string), isExplicit (bool), latestVersion (int), controllerOverrides (array), deprecated (?string), sunset (?string)
REST Layer (src/Rest/)
| File | Lines | Responsibility |
|---|---|---|
src/Rest/Auth/container.php | 46 | Registers VersionResolver in DI at line 37 |
src/Rest/Middleware/RequestContext.php | 51 | Extended with ?int $apiVersion = null at line 18; available for future version-aware middleware |
src/Rest/Middleware/PolicyResolver.php | 43 | Modified at lines 27-29 to add parent-class fallback so override controllers inherit the base controller's RBAC policy |
Legacy Layer (application/)
| File | Lines | Responsibility |
|---|---|---|
application/core/MY_Router.php | 83 | CI3 router override; adds ?int $apiVersion property (line 17-20) and version extraction in _parse_routes() (lines 31-37) |
application/controllers/RouterDispatcher.php | 125 | Central REST dispatch; version resolution, error responses, header injection, controller override swap (lines 22-60); passes $apiVersion into RequestContext (lines 94-100) |
application/modules/job/libraries/GenerateOpenApiJson.php | 328 | CLI job; generates per-version openapi-v{N}.json spec files and the api-versions.json manifest (lines 65-110); rewrites /rest/... paths to /rest/v{N}/... via prefixPaths() (lines 295-309) |
public/api-docs.html | 197 | Version-selector UI; fetches api-versions.json on load (line 177); renders sticky version dropdown with status badges (lines 74-141); reloads Scalar API reference on version change (lines 67-70) |
Configuration
application/config/rest_api_versions.php (57 lines)
| Key | Type | Purpose |
|---|---|---|
versions | array<int, array> | Version definitions keyed by major version integer |
versions.{N}.status | string | Lifecycle status: active, deprecated, or obsolete |
versions.{N}.released | string | Release date in Y-m-d format |
versions.{N}.deprecated | ?string | Deprecation date; populates RFC 9745 Deprecation header when set |
versions.{N}.sunset | ?string | Sunset date; populates RFC 8594 Sunset header when set |
versions.{N}.changelog | array<{version: string, date: string, summary: string}> | Structured changelog entries shown in the docs UI; legacy plain string is still accepted and auto-normalised by VersionResolver::normalizeChangelog() |
latest | int | Default version applied to unversioned /rest/{resource} requests |
controller_overrides | array<int, array> | Per-version map of BaseController::class => OverrideController::class; empty array means no overrides for that version |
No environment variables, registry keys, or feature flags are involved. There is no runtime config path.
DI Container Registration
VersionResolver is registered as a shared service:
src/Rest/Auth/container.php:37
php
$services->set(\Advisable\Rest\Versioning\VersionResolver::class);The src/Rest/Auth/container.php module is loaded via application/config/container/modules.php:49.
OpenAPI Generation
The per-version spec files are generated manually — there is no cron trigger:
bash
composer openapi-json
# or equivalently:
php cli.php job/GenerateOpenApiJsonThe job writes public/openapi-v{N}.json for each non-obsolete version, copies the latest to public/openapi.json for backward compatibility (lines 100-104), and writes public/api-versions.json as the version manifest consumed by api-docs.html (lines 106-110).
Client Extension Points
Adding a New Major Version
To introduce a new major version:
- Add an entry to
application/config/rest_api_versions.phpunderversionswithstatus: 'active'and updatelatest. - If the new version introduces breaking changes to specific endpoints, create override controller classes extending the base controllers.
- Register overrides in
controller_overrides[N]in the same config file. - Run
composer openapi-jsonto regenerate versioned spec files. - No changes to
application/config/rest_routes.phpare required — the router strips the version segment transparently.
Deprecating a Version
Change versions.{N}.status from active to deprecated and set deprecated and sunset dates. The platform will automatically inject Deprecation, Sunset, and Link response headers on all requests to that version. No code changes are required.
Override Controller Pattern
Version-override controllers live alongside their base controllers in src/Rest/{Context}/Controllers/. They extend the base controller and override only the breaking-change methods. The PolicyResolver parent-class fallback at src/Rest/Middleware/PolicyResolver.php:27-29 ensures the inherited RBAC policy continues to apply without any manual policy registration.
Business Rules
Unversioned requests always resolve to
latest. A request to/rest/product/productreceives the same response as/rest/v{latest}/product/product. Thelatestvalue in config is the single source of truth. (src/Rest/Versioning/VersionResolver.php:23-27)Unknown version numbers return 400, not 404. The 400 response returns a generic "Invalid API version" message without echoing the requested version number. (
application/controllers/RouterDispatcher.php:26-33)Obsolete versions are rejected at the router layer, not in controllers. The 410 response is returned by
RouterDispatcherbefore any controller is instantiated, so obsolete version traffic never touches business logic. (application/controllers/RouterDispatcher.php:35-50)Deprecation headers are injected on every response for deprecated versions. This applies equally to 2xx success, 4xx client error, and 5xx server error responses that reach the header-injection site. (
application/controllers/RouterDispatcher.php:58-60,src/Rest/Versioning/VersionResult.php:43-60)The version segment is stripped before route matching.
MY_Router::_parse_routes()rewrites the URI in place so thatrest_routes.phprequires no modification. (application/core/MY_Router.php:31-37)Controller override swap occurs before DI instantiation. The
$classvariable is replaced atRouterDispatcher.php:53-55beforedi()->get($class)at line 69. The override controller is indistinguishable from the base controller at the DI and middleware layers.Override controllers inherit RBAC policy from their parent class.
PolicyResolverfalls back toget_parent_class($controllerClass)when no direct policy entry exists. (src/Rest/Middleware/PolicyResolver.php:27-29)OpenAPI spec files are not generated automatically.
public/openapi-v{N}.jsonandpublic/api-versions.jsondo not exist untilcomposer openapi-jsonis run manually. (application/modules/job/libraries/GenerateOpenApiJson.php:65-110)Locale-prefixed versioned URLs are supported. The regex in
MY_Routerhandles bothrest/v{N}/...and{2-char locale}/rest/v{N}/...formats. (application/core/MY_Router.php:33)prefixPaths()only rewrites paths starting with/rest/. Webhook and non-REST paths are intentionally excluded from per-version spec generation. (application/modules/job/libraries/GenerateOpenApiJson.php:303)
Known Issues & Security Gaps
No rate limiting on version enumeration.Fixed: The 400 response for unknown versions no longer echoes back the requested version number — it returns a generic "Invalid API version" message. Version enumeration via response differentiation is no longer possible. Rate limiting remains a broader infrastructure concern. (application/controllers/RouterDispatcher.php:27-32)VersionResolverre-reads config on every non-shared instantiation. The constructor callsrequireonapplication/config/rest_api_versions.phpat construction time (src/Rest/Versioning/VersionResolver.php:11-27). Under current DI registration as a shared service this is once per request. If the service is ever re-registered as non-shared, each resolution call will re-read the file. The constructor now validates the config structure, but the file I/O concern remains.class_exists($controllerClass, false)inPolicyResolversilently fails for late-loaded classes. Thefalseargument disables autoloading. In the current dispatch order the controller is instantiated beforePolicyResolverruns, so the class is always loaded. If dispatch order changes, the parent-class RBAC fallback will silently return no policy rather than raise an error. (src/Rest/Middleware/PolicyResolver.php:27)Generated spec files do not exist until manually triggered.
public/openapi-v{N}.jsonandpublic/api-versions.jsonare absent from a fresh clone.api-docs.htmldegrades gracefully (falls back toopenapi.jsonat lines 184-193), but a missingopenapi.jsonwould produce a broken documentation page. No CI step generates these files. (public/api-docs.html:116-132)No integration or end-to-end tests for the version routing pipeline. The
MY_RouterURI extraction regex, theRouterDispatcherversion dispatch block, controller override swap, and response header injection are all exercised only by unit tests of individual components. No test drives an HTTP request through the full stack. (application/core/MY_Router.php:33,application/controllers/RouterDispatcher.php:22-60)Fixed: The middleware guide now includes a "Pre-pipeline: Version Resolution" section documenting the MY_Router extraction, VersionResolver dispatch, 400/410 handling, controller override swap, and header injection that runs before the 5-middleware chain. (docs/guides/rest-middleware.mdis stale with respect to versioning.docs/guides/rest-middleware.md:20-37)RFC 9745
Link rel="deprecation"header is not generated. The RFC recommends an optional link to a human-readable deprecation policy document.VersionResultgeneratesrel="successor-version"but notrel="deprecation". (src/Rest/Versioning/VersionResult.php:55)
Tests
Unit Tests
| File | Lines | Tests | What Is Covered |
|---|---|---|---|
tests/Unit/Rest/Versioning/VersionResolverTest.php | 280 | 12 | Explicit active/deprecated resolution; null → latest; unknown version → null; obsolete resolution; getActiveVersions() excludes obsolete but keeps deprecated; getLatestVersion(); constructor validation (missing file, missing keys, valid config) |
tests/Unit/Rest/Versioning/VersionResultTest.php | 149 | 8 | isActive/isDeprecated/isObsolete() predicates; header output for active (Api-Version only), deprecated (all 4 headers, IMF-fixdate format), deprecated without sunset, obsolete; override array passthrough |
Selected Test Cases (VersionResolverTest.php)
| Method | Lines | Assertion |
|---|---|---|
resolvesExplicitActiveVersion | 140-152 | Explicit v2 → active, isExplicit=true |
resolvesExplicitDeprecatedVersion | 155-167 | Explicit v1 → deprecated, override map populated |
resolvesNullToLatestVersion | 170-179 | Null → latest (v2), isExplicit=false |
returnsNullForUnknownVersion | 182-189 | v99 → null |
resolvesObsoleteVersion | 192-203 | Obsolete → isObsolete()=true |
getActiveVersionsExcludesObsolete | 206-217 | Obsolete filtered out |
getActiveVersionsIncludesDeprecated | 220-228 | Deprecated retained |
getLatestVersionReturnsConfigValue | 231-236 | Returns config latest value |
Coverage Gaps
- No tests for
MY_RouterURI extraction regex (application/core/MY_Router.php:33) - No integration tests for
RouterDispatcherversion dispatch (400/410 paths, header injection, override swap) - No tests for
GenerateOpenApiJsonper-version spec generation (application/modules/job/libraries/GenerateOpenApiJson.php:65-110) - No tests for
api-docs.htmlJavaScript version selector - No tests for
PolicyResolverparent-class fallback in a versioning context
Related Flows
- SY-19 Config Registry — The platform's general configuration pattern; versioning config uses a PHP file rather than the registry, by design.
- SY-22 Job Manager — Covers the CLI job infrastructure that
GenerateOpenApiJsonruns within. - SY-27 Deferred Task Runner — Background task infrastructure; version resolution in
RouterDispatchercompletes before any deferred tasks are flushed. - REST Middleware & RBAC guide — Documents the 5-middleware pipeline (Authentication → Authorization → ResourceContext → RelationFilter → Audit) that executes after version resolution; covers
PolicyResolverin detail.