Semantic Versioning for PHP Packages: Getting Version Numbers Right
AI generated
<?php
8.4
PHP 8.4 · Composer · SemVer · Packagist
Semantic Versioning for PHP Packages
getting version numbers right

A wrongly assigned version number breaks hundreds of other people's installations within seconds, often before the maintainer even notices. Semantic Versioning is the agreement the entire Composer ecosystem relies on: MAJOR for breaking changes, MINOR for new, backward-compatible features, PATCH for bugfixes. This article explains the rules in detail and shows Composer constraints and automated version detection with real PHP 8.4 code.

16 min read MAJOR.MINOR.PATCH · caret · tilde PHP 8.4 · Composer 2.x · Packagist

1. What Semantic Versioning solves and why Composer relies on it

Semantic Versioning, SemVer for short, solves a very concrete problem: without binding rules, a version number can mean anything or nothing, and nobody except the maintainer knows whether an update is safe. SemVer instead defines a fixed contract format, MAJOR.MINOR.PATCH, where each segment carries a clearly defined meaning. A user who knows the rules can tell from the version number alone whether an update is risk-free, purely additive, or potentially breaking, without reading the changelog.

Composer does not just assume Semantic Versioning, it builds its entire constraint mechanism on top of it. The caret operator ^1.2.0, for example, automatically allows all updates that SemVer guarantees to be backward compatible, but refuses any update that would introduce a new major version. This automation only works if package maintainers actually honor the promise behind Semantic Versioning, otherwise Composer installs seemingly safe updates that actually break the application.

For a single project, sloppy versioning might still be manageable, because a developer reads the changelog manually anyway. Once a package is used by dozens or hundreds of other projects, each of them relies automatically on Semantic Versioning, without a human reviewing every single update. This automation is the real value of SemVer, and it breaks the moment a maintainer ignores the rules even once.

2. The shape of a SemVer number: MAJOR, MINOR, PATCH

A complete SemVer number consists of three non-negative integers separated by dots: MAJOR.MINOR.PATCH, for example 2.4.1. The MAJOR number increases whenever an incompatible change is made to the public API, meaning any change that can break existing, correct caller code. A removed public parameter, a changed return type behavior, or a renamed class are classic reasons for a new major version under consistent Semantic Versioning.

The MINOR number goes up when new functionality is added in a backward-compatible way, such as an additional public method or a new optional parameter with a default value. Existing code written against the previous minor version must keep working unchanged. The PATCH number, finally, is reserved exclusively for backward-compatible bugfixes, never for new functionality. Anyone who accidentally adds a new method in a patch release violates Semantic Versioning, even if the change appears technically harmless.

As long as the MAJOR number is 0, a special rule applies under Semantic Versioning: the entire API is considered unstable, and even the MINOR number is allowed to introduce breaking changes. This is why many fresh packages deliberately start at 0.x.y, until the public API has proven itself in practice. The jump to 1.0.0 is thus a deliberate promise to all users that the API guarantees stability from that point on.


#!/usr/bin/env bash
# Semantic Versioning decision in practice, before tagging a release

# PATCH: bugfix only, no API change at all
git tag -a v2.4.2 -m "Fix rounding error in currency conversion"

# MINOR: new, backward-compatible feature
git tag -a v2.5.0 -m "Add optional locale parameter to formatted()"

# MAJOR: removed a deprecated method, breaking change
git tag -a v3.0.0 -m "Remove deprecated Money::fromFloat(), use fromCents() instead"

3. Version constraints in composer.json: caret, tilde, wildcard

Composer translates Semantic Versioning into concrete installation rules through constraint operators in composer.json. The caret operator ^2.4.1 is today's recommended default: it allows any update within the same major version, as long as it is greater than 0, but blocks any jump to a new major version. For packages with major version 0, the caret operator behaves more strictly and only allows updates within the same minor version, because under Semantic Versioning even minor jumps are allowed to break things for 0.x packages.

The tilde operator ~2.4.1 is more conservative and only allows updates to the last specified segment, so patch updates up to but excluding 2.5.0 here. If the minor segment is omitted, as in ~2.4, tilde allows minor updates up to but excluding 3.0.0. This subtle difference is often confused, but leads to differently strict update boundaries in practice and should be chosen deliberately, depending on how much trust exists in a given package's Semantic Versioning discipline.

Wildcard constraints like 2.4.* and explicit ranges like >=2.4.0 <3.0.0 offer further precision for edge cases, for example when a package is known to have a problematic intermediate state between two specific versions. In the vast majority of cases, the caret operator is enough, because it most directly matches the actual promise of Semantic Versioning: compatible updates automatically, breaking updates never.


{
    "require": {
        "php": "^8.2",
        "mironsoft/money-value": "^2.4",
        "psr/log": "^3.0 || ^2.0 || ^1.1",
        "symfony/console": "~7.1.0",
        "some/legacy-package": ">=2.4.0 <3.0.0"
    }
}

4. Spotting and correctly classifying breaking changes

Not every obvious code change is also a breaking change in the sense of Semantic Versioning, and not every inconspicuous change is harmless. Removing a public method is always a breaking change. Less obvious: if a method's return type is widened from string to string|null, that is also a breaking change for callers who so far blindly work with the return value, even though the signature was only formally extended.

Changes to internal classes not declared public, on the other hand, do not count as a breaking change, as long as the public API stays unchanged. This is exactly why a deliberately narrow public API pays off: the less code is exposed publicly, the more room there is for internal refactoring without having to bump the major number. A package that accidentally exposes too many internal details forces itself into more frequent major releases than would actually be necessary.

Behavioral changes without a signature change also count under consistent Semantic Versioning: if a method newly throws an exception that it never threw before, existing caller code that does not catch this exception can break at runtime. Such behavioral breaking changes are the most commonly overlooked in practice, because they evade static analysis and only become visible through careful changelog maintenance and manual review.

5. Using pre-release and build metadata correctly

Semantic Versioning allows additional pre-release identifiers after a hyphen, such as 3.0.0-beta.1 or 3.0.0-rc.2. These identifiers mark a version that has formally already reached the next stage but is not yet considered stable. Composer does not install such pre-releases by default, unless a user explicitly lowers minimum-stability or requests the version directly with an @beta stability flag on the requirement.

Build metadata after a plus sign, such as 3.0.0+build.20260730, carries additional information that is irrelevant to the version comparison itself. Two versions that differ only in build metadata are considered equal under the Semantic Versioning specification, Composer ignores this part entirely during constraint matching. Build metadata is rare in the PHP package world, because Git tags themselves already serve as a unique identifier for a commit.

A sensible use of pre-releases: before a larger major release, for example switching the minimum requirement from PHP 8.3 to PHP 8.4, several rc versions are published that interested users can deliberately run against their own test suite before the final, stable version appears. This practice significantly reduces the risk of undiscovered breaking changes without violating the regular Semantic Versioning chain.


#!/usr/bin/env bash
set -euo pipefail

# Publish a release candidate before the final major version
git tag -a v3.0.0-rc.1 -m "Release candidate: PHP 8.4 baseline"
git push origin v3.0.0-rc.1

# Interested users can opt in explicitly:
# composer require mironsoft/money-value:3.0.0-rc.1

# Once validated, tag the stable release
git tag -a v3.0.0 -m "Stable release: PHP 8.4 baseline"
git push origin v3.0.0

6. Composer's own version comparison algorithm

Composer normalizes every version number internally before a comparison happens, handling edge cases that pure Semantic Versioning does not define on its own. A prefix v, as in v2.4.1, is ignored during comparison, but is a widely used convention for Git tags and functionally equivalent to 2.4.1. Composer also accepts shortened version numbers like 2.4 and internally fills the missing patch segment with 0.

For the actual comparison, Composer splits the version number into its numeric parts and compares them left to right, MAJOR first, then MINOR, then PATCH. Stability suffixes like -dev, -alpha, -beta, -RC, and finally the stable version without a suffix, form a fixed, ascending order, so 2.0.0-beta1 reliably counts as less than 2.0.0 under Semantic Versioning logic.

An often overlooked edge case: Composer treats dev-main and similar branch aliases outside the regular SemVer ordering as their own stability level, considered the least stable by default. Anyone who accidentally references a branch name instead of a tag in require implicitly leaves the guarantees of Semantic Versioning, because a branch's content can change at any time without the version specification itself changing.

7. Automated version detection with Conventional Commits

Conventional Commits complement Semantic Versioning with a machine-readable commit convention: fix: for bugfixes, feat: for new features, and a BREAKING CHANGE: note in the commit body or an exclamation mark after the type, such as feat!:, for breaking changes. Tools like semantic-release or the PHP counterpart conventional-changelog read this commit history automatically and derive the correct next version number from it, without a human having to decide manually.

The benefit of this automation lies not only in saved time but in enforced consistency: an automated tool applies the rules of Semantic Versioning mechanically, without the human tendency to declare a breaking change as a minor release out of convenience. The prerequisite, however, is that every commit is actually classified correctly, a mistagged commit type otherwise propagates directly into a wrong version number.

This process can be fully automated in the CI pipeline: after every merge into the main branch, a job analyzes the commit history since the last tag, determines the next version under Semantic Versioning, and creates the tag and changelog entry automatically. For teams with a high release frequency, this significantly reduces manual effort without endangering the reliability of the version numbers.

8. Common SemVer mistakes in practice

The most common mistake with Semantic Versioning is accidentally publishing a breaking change as a minor or even patch release, usually because the change seemed harmless to the maintainer. A second typical mistake concerns patch releases that actually contain new functionality, for example because a bugfix incidentally introduces an additional public method. Both undermine the trust Composer's automated updates rely on.


<?php

declare(strict_types=1);

/**
 * WRONG: this method signature change is a breaking change,
 * but shipping it as v2.5.1 (a patch release) violates Semantic Versioning.
 * Existing callers that rely on the exact return type will fail.
 */
final class LegacyExample
{
    // Before v2.5.0: public function total(): float
    // After v2.5.1 (WRONG, should be v3.0.0):
    public function total(): string
    {
        return number_format($this->amount, 2);
    }
}

/**
 * RIGHT: add a new method alongside the existing one (v2.5.0, a minor
 * release), deprecate the old one, and only remove it in v3.0.0.
 */
final class CorrectExample
{
    public function total(): float
    {
        return $this->amount;
    }

    /** @deprecated since 2.5.0, use total() and format the result yourself */
    public function formattedTotal(): string
    {
        return number_format($this->amount, 2);
    }
}

A third mistake concerns transitive dependencies: a package that internally references a third-party library with an overly loose constraint like * can break through a breaking-change update of that dependency without having published a new version itself. Consistent Semantic Versioning therefore demands precise constraints for your own dependencies too, not just for the version number you communicate to the outside.

9. Constraint syntax compared directly

Composer offers several constraint operators with differently strict update boundaries. The following overview shows which operator opens up which part of the SemVer number for updates, and when each one fits best.

Constraint Example Allowed updates Recommendation
Caret ^ ^2.4.1 All 2.x.y, never 3.0.0 Default choice for most dependencies
Tilde ~ ~2.4.1 Only 2.4.x, never 2.5.0 For less trusted packages
Wildcard * 2.4.* Only 2.4.x, identical to tilde here Rarely needed, caret is clearer
Range >=2.4.0 <3.0.0 An explicitly defined range Only for documented edge cases
Exact 2.4.1 None, fully fixed Only for known unstable packages

The caret operator covers the vast majority of sensible cases in practice, because it exactly matches the guarantee Semantic Versioning promises: compatible updates automatically, breaking updates never uninvited. Exact version pinning should remain the exception, because it blocks security updates that a patch release would otherwise deliver risk-free.

Mironsoft

PHP architecture, release processes and package maintenance

Did an update just quietly break production?

We set up clean Semantic Versioning processes for your PHP packages, automate version detection through Conventional Commits, and make sure Composer updates reliably do what their version number promises.

Versioning audit

Review existing release history for SemVer violations

Release automation

Conventional Commits and automated version detection in CI

Composer consulting

Define a constraint strategy for dependencies across the whole project

10. Summary

Semantic Versioning is the contract format Composer's entire automated update logic relies on: MAJOR for breaking changes, MINOR for backward-compatible new features, PATCH for pure bugfixes. The caret operator translates this promise directly into a constraint rule and allows exactly the updates that SemVer says should be safe. Pre-release identifiers like -beta and -rc enable controlled testing before a larger release without violating the regular version chain.

Anyone who applies Semantic Versioning consistently, even for inconspicuous behavioral changes and internal dependencies, gives every user of a package the ability to install updates blindly and automatically. Automated version detection through Conventional Commits further reduces human misjudgment and turns compliance with the rules into a technical process rather than a purely disciplinary one.

Semantic Versioning for PHP packages: the key points at a glance

Shape

MAJOR.MINOR.PATCH: breaking change, new feature, bugfix. A clear, binding meaning for each segment.

Constraints

Caret ^ as the default, allows compatible updates, blocks new major versions automatically.

Pre-releases

-beta, -rc for controlled testing before a larger release, Composer does not install them by default.

Most common mistake

A breaking change accidentally declared as minor or patch, breaking automation for other people's projects.

11. FAQ: Semantic Versioning for PHP Packages

1What does Semantic Versioning actually mean?
MAJOR.MINOR.PATCH: breaking change, new backward-compatible feature, pure bugfix. The version number signals update risk.
2Difference between caret and tilde?
Caret allows updates within the major version, tilde only within the last specified segment, so much narrower.
3Why a special rule at 0.x.y?
The API is considered unstable, even minor jumps may break things. 1.0.0 is the first stability promise.
4Widened return type a breaking change?
Yes, if it can break existing caller code, such as string to string|null, requiring a new major version.
5Does Composer auto-install pre-releases?
No, only with a lowered minimum-stability or an explicit stability flag request.
6What are Conventional Commits?
A commit convention with fix:, feat: and BREAKING CHANGE:, from which tools automatically derive the next version.
7Bugfix accidentally mixed with a feature?
Violates SemVer, users pulling only patch updates receive unwanted new functionality they did not expect.
8How does Composer compare versions?
Normalizes prefixes, compares MAJOR, MINOR, PATCH segment by segment, stability suffixes form a fixed order.
9Does build metadata count in comparison?
No, versions with different build metadata are considered equal, Composer ignores this part.
10Always pin an exact version?
Generally no, that also blocks safe patch updates. Caret is the better choice for most dependencies.