API Versioning in PHP: URI, Headers and Content Negotiation
AI generated
<?php
8.4
PHP · API Versioning · Breaking Changes · Backend
API Versioning in PHP
URI, header or content negotiation: which strategy actually holds up

Without a deliberate API versioning strategy, every breaking change turns into a crisis meeting with external integrators. In PHP, three established approaches are available: a version number in the URI, a dedicated version header, or versioning through the Accept header via content negotiation. Which API versioning approach fits depends on your customer structure, release rhythm and the number of versions supported in parallel.

17 min read URI · Header · Content Negotiation · Deprecation PHP 8.4 · framework agnostic

1. Why API versioning is necessary at all

An API consumed by external systems cannot be reshaped at will the way an internal library can. As soon as a field is renamed, an endpoint removed, or a response format changed, every client relying on the old structure breaks. API versioning solves exactly this problem: it allows a new version to be offered alongside the old one, so existing integrations keep running while new clients already use the improved version.

Without API versioning, only two unattractive options remain: either the API is frozen permanently, which blocks further development, or it gets changed and risks breaking partner integrations without warning. Neither option is acceptable in production PHP systems with external consumers. A deliberate API versioning strategy creates the room needed to enable innovation and stability at the same time, instead of forcing a choice between the two.

2. URI versioning: implementing /v1/, /v2/ in PHP

The most common form of API versioning is putting the version number directly in the URL, for example /v1/orders and /v2/orders. The big advantage: the version is visible at a glance, traceable in logs, browser address bars and API documentation without extra tooling, and works with any HTTP client without special header handling. From a support perspective this is a significant benefit, because a bug report containing a URL already reveals which version was in use.

The downside of this API versioning approach is that it strictly violates the REST principle that the same resource should be reachable under the same URI. /v1/orders/5 and /v2/orders/5 represent the same business resource under two different addresses. In practice the pragmatic benefit usually outweighs this theoretical objection by a wide margin, which is why URI versioning, despite the criticism, remains the most frequently used form of API versioning.


<?php

declare(strict_types=1);

// URI-based API versioning: dispatch by version prefix
final class VersionedRouter
{
    /** @var array<string, ControllerInterface> */
    private array $versions = [];

    public function registerVersion(string $version, ControllerInterface $controller): void
    {
        $this->versions[$version] = $controller;
    }

    public function dispatch(string $path, string $method, array $payload): mixed
    {
        if (!preg_match('#^/(v\d+)/(.+)$#', $path, $matches)) {
            http_response_code(400);
            return ['error' => 'Missing API version in path'];
        }

        [, $version, $resource] = $matches;

        if (!isset($this->versions[$version])) {
            http_response_code(404);
            return ['error' => "API version {$version} is not supported"];
        }

        return $this->versions[$version]->handle($resource, $method, $payload);
    }
}

$router = new VersionedRouter();
$router->registerVersion('v1', new OrderControllerV1());
$router->registerVersion('v2', new OrderControllerV2()); // adds pagination cursor

Instead of writing the version into the URL, it can also be transmitted through a dedicated header, for example Api-Version: 2. This form of API versioning keeps the URL clean and stable and treats the version as request metadata rather than part of the resource address, which is closer to the REST mindset. For internal microservices where all clients are under your own control, this is a good choice, because header handling can be configured centrally in an HTTP client library.

The downside shows up mainly with public APIs that have many external consumers: a missing or incorrectly set header is less obvious to developers than a wrong URL, and many simple HTTP testing tools, browser address bars or Postman collections without maintained header presets quickly lead to incorrectly versioned requests. For internal API versioning between controlled teams, the header approach is nonetheless one of the cleanest solutions.


<?php

declare(strict_types=1);

// Header-based API versioning: version as request metadata
final class HeaderVersionResolver
{
    public function __construct(
        private readonly string $defaultVersion = '1',
        private readonly array $supportedVersions = ['1', '2'],
    ) {}

    public function resolve(?string $headerValue): string
    {
        $version = $headerValue ?? $this->defaultVersion;

        if (!in_array($version, $this->supportedVersions, true)) {
            http_response_code(400);
            header('Content-Type: application/json');
            echo json_encode([
                'error' => "Api-Version '{$version}' is not supported",
                'supported_versions' => $this->supportedVersions,
            ]);
            exit;
        }

        return $version;
    }
}

$resolver = new HeaderVersionResolver();
$version = $resolver->resolve($_SERVER['HTTP_API_VERSION'] ?? null);

$controller = match ($version) {
    '1' => new OrderControllerV1(),
    '2' => new OrderControllerV2(),
};

4. Content negotiation: versioning via the Accept header

The third common API versioning strategy uses media type parameters inside the Accept header, for example Accept: application/vnd.mironsoft.v2+json. Many consider this the theoretically cleanest form of API versioning, because it uses HTTP content negotiation, a mechanism designed exactly for such negotiations, instead of artificially squeezing versioning into the URL or a custom header.

In practice this form of API versioning runs into adoption problems: the header is more cumbersome to write, many API gateways and caches do not handle vendor media types consistently correctly, and discoverability for new developers is lower than with a visible version number in the URL. For APIs with strict hypermedia and REST maturity requirements, content negotiation remains the variant closest to the REST ideal, even though it is used less broadly than URI versioning.


<?php

declare(strict_types=1);

// Content-negotiation based versioning via vendor media type
final class VendorMediaTypeVersionResolver
{
    public function resolve(string $acceptHeader): string
    {
        if (preg_match('#application/vnd\.mironsoft\.v(\d+)\+json#', $acceptHeader, $matches)) {
            return $matches[1];
        }

        // Fall back to the latest stable version if no vendor type is given
        return '2';
    }
}

$resolver = new VendorMediaTypeVersionResolver();
$version = $resolver->resolve($_SERVER['HTTP_ACCEPT'] ?? '');

header("Content-Type: application/vnd.mironsoft.v{$version}+json");

5. Semantic versioning for API contracts

Semantic versioning, meaning the MAJOR.MINOR.PATCH structure, originally comes from library and package management but transfers sensibly to API versioning. A MAJOR bump signals breaking changes, a MINOR bump signals new, backward compatible fields or endpoints, and a PATCH marks pure bug fixes without behavior changes. In practice, public HTTP APIs usually only surface the MAJOR version in the URL or header, because MINOR and PATCH changes must be backward compatible by definition.

This convention creates a shared language between the team and consumers: when API versioning is communicated strictly according to semantic versioning, integrators know immediately from a changelog whether an update can be pulled in risk free or requires an adjustment to their own code. Without this discipline, breaking changes and harmless extensions blur into the same version number, undermining trust in the API versioning overall.

6. What actually counts as a breaking change

Not every change to an API justifies a new major version, but in practice the line is often drawn incorrectly. A new optional field in the response is not a breaking change, because well written clients ignore unknown fields. Removing an existing field, changing its type, or making a previously optional validation rule stricter, on the other hand, are classic breaking changes that require a new version in the API versioning scheme.

A particularly underestimated case: changing the order of array elements looks harmless but can break clients that rely on index positions instead of named keys. Likewise, tightening rate limits or introducing a previously optional required field validation counts as a breaking change, even though not a single line of the response structure was changed. A clean API versioning strategy therefore needs a documented definition, binding for the whole team, of what counts as a breaking change.

7. Deprecation workflow: retiring old versions cleanly

API versioning without a deprecation strategy leads to old versions having to run indefinitely, because nobody knows who still uses them. The first step is visibility: a Deprecation header per RFC 8594 informs clients, in a machine readable way, that the version in use is deprecated, complemented by a Sunset header carrying the planned shutdown date. These headers can be attached to every response without changing the actual response body.

The second step is monitoring: every old API version should be logged, including client identification, so the remaining users can be contacted specifically before final shutdown. A common window lies between six and twelve months between announcement and shutdown, depending on how critical the API is for external partners. An API versioning strategy that considers this process from the start avoids the situation of having to keep old versions alive indefinitely out of fear of outages.


<?php

declare(strict_types=1);

// Deprecation headers per RFC 8594, attached without touching the body
final class DeprecationMiddleware
{
    /** @param array<string, array{sunset: string}> $deprecated */
    public function __construct(private readonly array $deprecated) {}

    public function apply(string $version): void
    {
        if (!isset($this->deprecated[$version])) {
            return;
        }

        header('Deprecation: true');
        header("Sunset: {$this->deprecated[$version]['sunset']}");
        header('Link: <https://mironsoft.de/docs/api/v2>; rel="successor-version"');
    }
}

$middleware = new DeprecationMiddleware([
    '1' => ['sunset' => 'Sat, 01 Nov 2026 00:00:00 GMT'],
]);
$middleware->apply($version);

8. Maintaining multiple versions in one PHP codebase

The technical challenge with API versioning rarely lies in detecting the version, but in maintaining several parallel behaviors within the same codebase. A proven pattern is to give each version its own controller that shares the same domain layer underneath. Only the transformation from domain object to API response differs between versions, while business logic, validation and persistence remain version independent.

A common mistake is scattering version branches with if conditions throughout the business logic, for example if ($version === 'v1') { ... } in the middle of a service method. This quickly makes API versioning unmaintainable, because every new version increases the number of branches in existing code. Cleaner is a presenter or serializer layer per version that leaves domain logic untouched and only adjusts the output shape.

9. API versioning side by side

The following table contrasts the three API versioning strategies with their respective strengths and weaknesses, as a quick decision aid for your own project.

Strategy Visibility REST compliance Recommendation
URI versioning Very high Debated Public APIs with many external consumers
Custom header Medium High Internal microservices under your own control
Accept header / vendor type Low Very high APIs with strict hypermedia requirements
No deprecation header No advance warning Always avoid, regardless of strategy

The table shows there is no universally correct API versioning strategy, only a tradeoff between visibility for external developers and theoretical REST compliance. What always remains mandatory, regardless of the chosen strategy, is a clean deprecation process with lead time.

Mironsoft

PHP backend development and API architecture

API versioning that doesn't turn breaking changes into a crisis?

We design an API versioning strategy that fits your customer structure, including a deprecation process and a clean separation between domain logic and version specific output.

Strategy selection

Choosing URI, header or content negotiation to match your consumer structure

Deprecation process

Setting up RFC 8594 headers, monitoring and planned shutdown windows

Codebase refactoring

Presenter layer instead of version branches in business logic

10. Summary

API versioning is not a one-time technical detail, it is a long term matter of trust with everyone consuming the API. URI versioning offers the highest visibility, header versioning a cleaner separation of resource and metadata, and content negotiation the theoretically purest REST solution. For most teams with external consumers, URI versioning is the most pragmatic starting point, complemented by semantic versioning for clear communication of breaking changes.

The decisive success factor lies less in the chosen strategy than in the accompanying process: a clear definition of what counts as a breaking change, a deprecation workflow with RFC 8594 headers and sufficient lead time, and a codebase that encapsulates version differences in its own presenter layer instead of scattering them through business logic. Anyone who implements these three building blocks consistently turns API versioning into a plannable process instead of a series of surprising support tickets.

API Versioning in PHP — The Essentials at a Glance

URI vs. header

URI versioning is the most visible, header versioning cleaner in REST terms. Both are implementable in PHP with little code.

Semantic versioning

MAJOR for breaking changes, MINOR for backward compatible extensions, PATCH for bug fixes without behavior changes.

Deprecation

RFC 8594 headers Deprecation and Sunset give consumers plannable lead time before shutdown.

Codebase

Encapsulate version differences in a presenter layer, business logic remains version independent.

11. FAQ: API Versioning in PHP

1Which strategy for public APIs?
Usually URI versioning for its high visibility, complemented by semantic versioning for clear communication.
2Does URI versioning break REST?
Strictly speaking yes, but in practice the benefit for visibility and simplicity usually outweighs it.
3When is something a breaking change?
When fields are removed, types changed, or validation tightened. New optional fields usually do not count.
4How long to support old versions?
Common practice is six to twelve months between announcement and shutdown, depending on criticality for external partners.
5What does the Deprecation header do?
Informs clients machine readably about deprecation, complemented by a Sunset date, without changing the response body.
6Keeping version branches clean?
Use a presenter layer per version, business logic remains unchanged and version independent.
7What do MAJOR, MINOR, PATCH mean?
MAJOR for breaking changes, MINOR for backward compatible extensions, PATCH for pure bug fixes.
8Content negotiation for small teams?
Usually not the best starting point due to higher entry barrier, but suitable for strict internal REST requirements.
9Who uses which version?
Consistent logging per version and client identification, to contact remaining users specifically before shutdown.
10Reordering arrays: breaking change?
Can be, if clients rely on index positions rather than named keys. Documentation should make this explicit.