Why an API version is not a library version, and how to derive the right bump automatically
For a library, a developer decides at release time whether a change is breaking and bumps the version number accordingly. For a REST API that is not enough: the contract between the server and dozens of unknown clients changes the moment the OpenAPI specification changes, and nobody on the team can manually track which of ten parallel feature branches just removed a required field. This article shows how to detect breaking changes automatically from OpenAPI diffs and enforce the correct version bump through CI, instead of relying on individual developers to remember.
Table of Contents
- 1. Why SemVer behaves differently for APIs than for libraries
- 2. What actually counts as a breaking change for a REST API?
- 3. Drawing a clean line between minor and patch changes
- 4. Using OpenAPI diffs to derive the version bump automatically
- 5. Tooling for automated breaking change detection
- 6. A CI pipeline that prevents version violations
- 7. Comparing URL versioning, header versioning, and content negotiation
- 8. Using deprecation and sunset headers correctly
- 9. Practical example: SemVer enforcement in a Symfony project
- 10. Summary
- 11. FAQ
1. Why SemVer behaves differently for APIs than for libraries
With an npm package or a Composer package, the consumer is known: they explicitly listed the library in their package.json or composer.json and control, through a version range, exactly when they pull an update. A major bump does not break anything immediately there, because the developer must actively run composer update to receive the new version and can review the change at their own pace. The version number is therefore primarily information for a human who makes a deliberate decision.
With a REST API the situation is reversed: a client calls a URL, and the moment the server starts returning a new response shape, every caller feels that change instantly and without warning, whether or not they were prepared for it. There is no lock file that pins an old version, and often the backend team does not even know all the consumers, especially for public APIs or internal systems that have grown organically over years. That is exactly why the version number of an API cannot just document a change, it has to actively gate access, typically through a URL path or a header that targets a specific contract version.
2. What actually counts as a breaking change for a REST API?
A breaking change exists whenever a client that strictly follows the previous OpenAPI specification stops working after the change. Classic cases include removing a response field a client reads, renaming a field, changing a data type (say, from string to integer), removing an enum value a client switches on, or adding a new required request parameter that existing clients naturally do not send. Changing the HTTP status code for the same error case also counts, if clients branch their error handling on that code.
Not every change that looks risky at first glance is actually breaking. A new optional field in the response breaks nothing under standard REST conventions, as long as clients ignore unknown fields, which is the default behavior of any well written JSON parser. Likewise a brand new endpoint is purely additive and therefore harmless, since no existing client calls it. The dividing line is not 'does something change' but 'can an existing, spec-compliant client fail because of this'. That distinction is the foundation of any automated detection, because it can be derived structurally from the OpenAPI document itself.
3. Drawing a clean line between minor and patch changes
A minor version signals additive, backward compatible extensions for an API: a new optional query parameter, an extra field in the response, a new endpoint, or an additional optional enum value that older clients simply do not know about and therefore ignore. A patch version, on the other hand, does not touch the contract at all, it fixes internal behavior: a bug in the business logic, a performance improvement, a fixed rounding error, as long as the OpenAPI specification remains structurally unchanged. In practice it pays off to make this boundary visible directly in the code, for example through attributes on Symfony controllers that document the minimum API version per endpoint.
The example below shows how such a minor extension can be marked cleanly in a Symfony controller, by explicitly documenting since which version the new, optional parameter is available. This annotation is not just for readability, a dedicated compiler pass or an OpenAPI generator can later evaluate it to check whether an endpoint declared as minor really only contains additive changes.
<?php
declare(strict_types=1);
namespace App\Controller\Api;
use App\Attribute\ApiVersion;
use App\Dto\OrderCollectionFilterDto;
use App\Repository\OrderRepository;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\Routing\Attribute\Route;
/**
* Returns a list of orders for the currently authenticated tenant.
*/
final class OrderListController
{
public function __construct(
private readonly OrderRepository $orderRepository,
) {
}
/**
* Returns the order list. The optional "status" parameter was added
* additively in version 2.3.0 (minor bump), since existing clients
* keep working unchanged without it.
*
* @param Request $request The current HTTP request
* @return JsonResponse The JSON serialized order list
*/
#[Route('/api/v2/orders', methods: ['GET'])]
#[ApiVersion(since: '2.3.0', breaking: false)]
public function __invoke(Request $request): JsonResponse
{
$filter = OrderCollectionFilterDto::fromRequest($request);
$orders = $this->orderRepository->findByFilter($filter);
return new JsonResponse([
'data' => $orders,
'meta' => ['count' => \count($orders)],
]);
}
}
4. Using OpenAPI diffs to derive the version bump automatically
The most reliable way to determine the correct version bump does not rely on a developer's self-assessment, it relies on a structural comparison of two OpenAPI documents. You export the specification of the last published state, generate the new document from the current branch, and let a diff tool compare both states path by path, parameter by parameter, and schema by schema. The result is a structured list of changes that falls into exactly three categories: additive and therefore minor, removing or type-changing and therefore major, or purely cosmetic (say, a changed description) and therefore without version impact.
From this classification the next version number can be derived mechanically, much like semantic-release does for libraries from commit messages, except that here the source of truth is not the commit text but the actual structure of the interface. That is a meaningful advantage over commit conventions like Conventional Commits, because a developer can misjudge or simply forget to add a 'BREAKING CHANGE' prefix while writing a commit message, whereas a structural diff of the OpenAPI file catches the change reliably regardless of how it was described.
5. Tooling for automated breaking change detection
The tool oasdiff has become the de facto standard for OpenAPI diffs in recent years, because it does not just report textual differences, it understands semantically that a reordered set of properties in a schema is irrelevant while removing a required field is critical. Running oasdiff breaking old.yaml new.yaml returns a list of concrete breaking changes with file path, affected endpoint, and a human readable description, and the process exit code directly signals whether a break exists at all, which fits automation extremely well.
Alternatives include openapi-diff from OpenAPITools or commercial solutions like Optic, which additionally visualize a history across multiple versions in a web interface. For a Symfony project that generates its OpenAPI specification through NelmioApiDocBundle, a two-stage process works well: the specification is stored as an artifact on every merge into the main branch, then a separate CI job compares that archived version against the version generated from the current feature branch. That produces a gap-free history that always lets you trace when a change was introduced and whether it was versioned correctly.
6. A CI pipeline that prevents version violations
Detection alone is not enough as long as it only produces a warning that gets buried in a pull request comment. The approach only becomes effective once the CI pipeline actively blocks the merge whenever a detected breaking change is not matched by an increased major version number. Concretely that means a pipeline step checking three things: first, whether oasdiff breaking reports any changes, second, whether the version file kept in the project (say a VERSION file or a Composer tag) actually contains a new major version, and third, whether the pull request explicitly carries a label such as breaking-change that forces a deliberate human confirmation.
If any of these three conditions is missing, the build fails, and the developer must either make the change genuinely backward compatible, for instance by adding an optional field instead of removing a required one, or deliberately bump the version number and document the consequences in the pull request description. This forced stop is inconvenient, and that is exactly the point: it prevents a breaking change from slipping through disguised as a harmless extension because a developer was under time pressure or simply did not grasp the full scope of the change. In larger teams this mechanism pays for itself within a few weeks, as soon as it has prevented its first silent production outage.
7. Comparing URL versioning, header versioning, and content negotiation
The version number derived from an OpenAPI diff eventually has to land somewhere in the actual HTTP traffic, and there are three established strategies for that. URL versioning, meaning a prefix like /api/v2/orders, is the simplest to implement and immediately visible to developers, but every new major version effectively requires a second, parallel route structure, and caching layers have to carry the version number as part of the URL. Header versioning through a dedicated header like Api-Version: 2026-08-07 or X-API-Version: 2 keeps the URL stable and works well for internal APIs, but requires clients to actively set that header, which is easy to forget.
Content negotiation via the Accept header, say Accept: application/vnd.mycompany.v2+json, is considered the 'purest' solution in the REST community because it follows the actual HTTP mechanism for format negotiation, but in practice it is rarely applied consistently, because it feels unfamiliar to frontend developers and many HTTP clients and proxies do not evaluate the Accept header as granularly as expected. In practice, most teams settle on a middle ground: URL versioning for major versions, combined with additive, backward compatible minor and patch changes within the same URL version, so that a major bump happens rarely enough to justify the overhead of a parallel route structure.
8. Using deprecation and sunset headers correctly
Once a new major version is live, the real challenge only begins: the old version has to be retired eventually without surprising clients. The Deprecation HTTP header signals since when an endpoint is considered outdated, while the Sunset header (per RFC 8594) states a concrete date after which the endpoint will no longer be reachable. Both headers can be set automatically in Symfony through an EventSubscriber on the ResponseEvent level for all routes marked as deprecated, so any team still using the old route sees the information directly in the HTTP response, without anyone having to write an email.
It also helps to add a Link header per RFC 8288 with rel="successor-version" that points straight to the documentation of the new version, so a client developer debugging an issue immediately finds the next step. In practice the gap between deprecation and actual shutdown should follow the release cadence of the most important consumers, six to twelve months is common for public APIs, while a few weeks is often enough for internal APIs with a small, known set of consumers. What matters is communicating this date firmly and encoding it technically in the Sunset header, instead of burying it in a wiki page nobody reads.
9. Practical example: SemVer enforcement in a Symfony project
In a concrete Symfony project, the full workflow looks like this: every merge into the main branch triggers a GitHub Actions job that first exports the current OpenAPI specification as a YAML file through NelmioApiDocBundle and archives it as an artifact. On every new pull request, another job downloads the most recently archived specification, generates the new version from the feature branch, and runs oasdiff breaking against both files. If the tool reports breaking changes, a small shell script checks whether the composer.json file in the same pull request already contains a new major version in its version field, and fails with a clear error message if it does not.
This setup has proven itself in practice because it does not require any extra infrastructure, builds directly on existing Composer and CI mechanisms, and gives developers immediate, understandable feedback instead of only surfacing the mistake in production. The most important effect is indirect: because breaking changes become visible and costly through the forced stop, teams gradually develop an instinct for shaping changes additively from the start, for example through new optional fields instead of renaming existing ones, which noticeably reduces how often a major version is even needed.
| Change type | Impact on clients | Version bump | Example |
|---|---|---|---|
| New optional response field | No break, unknown fields are ignored | Minor | Field "discountPercentage" added |
| New endpoint | No break, purely additive | Minor | POST /api/v2/orders/{id}/cancel |
| Required field removed from response | Break, client parsing fails | Major | Field "legacyId" removed |
| Enum value removed | Break, if a client switches on it | Major | Status "pending_review" removed |
| Bugfix without contract change | No break, only internal behavior fixed | Patch | Incorrect rounding behavior fixed |
| New required request parameter | Break, existing requests fail | Major | Field "tenantId" becomes required |
Mironsoft
OpenAPI design, Symfony APIs, and API security
APIs that external teams can integrate without back-and-forth questions?
We review existing REST APIs for inconsistent error formats, missing OpenAPI documentation, and security gaps, then build an API that is clearly documented, versioned, and hardened against abuse.
API Review
Checking the OpenAPI spec, error formats, and status codes for consistency.
Symfony Implementation
Using DTOs, Serializer, and Validator for clean, type-safe request/response models.
Security Audit
Hardening rate limiting, auth schemes, and input validation against real attack surfaces.
10. Summary
Semantic Versioning for APIs: The Key Points at a Glance
Patch
Internal behavior is fixed, the OpenAPI contract stays structurally unchanged.
Minor
Purely additive, backward compatible extension, existing clients keep working unchanged.
Major
A spec-compliant client can fail after the change.
Tooling
oasdiff compares two OpenAPI documents structurally and reports breaking changes in a machine readable form.