Composer Version Constraints: Ranges, Caret, Tilde, and Stability
Read and write Composer version constraint syntax correctly — exact versions, comparison ranges, caret (^), tilde (~), and wildcard (*) — and understand stability flags, minimum-stability, prefer-stable, and how transitive dependency constraints combine during resolution.
A Composer constraint string like ^7.4 or ~1.2.0 is not decoration — it precisely controls which future package versions your project will accept during composer update, and getting it wrong is one of the most common sources of both 'why did this break after a routine update' and 'why won't this even install' problems in real PHP projects. Composer constraints follow Semantic Versioning (semver) conventions, so understanding them is really understanding how semver's major.minor.patch promise maps onto a compact syntax.
Exact versions and comparison operators
The simplest constraint is an exact version string, e.g. "1.4.2", which locks to that single version and nothing else — useful for a package known to have breaking changes even in patch releases, but generally too rigid for normal use since it blocks even safe bug-fix updates. Comparison operators build ranges directly: >=1.0, <2.0, >1.0,<2.0 (comma means AND — both must hold), and !=1.5.3 excludes a specific known-bad version while otherwise allowing a range.
These explicit ranges are verbose but fully unambiguous, and are commonly reached for when a package's real compatibility window doesn't line up neatly with semver conventions — for example excluding one specific version known to have a regression: "^2.0,!=2.3.1".
Caret (^): the recommended default
The caret constraint ^1.2.3 allows any version that doesn't change the leftmost non-zero digit — in practice, for a version 1.2.3, that means >=1.2.3 <2.0.0: any 1.x release equal to or newer than 1.2.3, but nothing from 2.0.0 onward. This directly encodes semantic versioning's promise that major version bumps may break compatibility while minor and patch bumps should not, which is exactly the risk tolerance most projects want: pick up bug fixes and new backward-compatible features automatically, but never silently jump to a breaking major version.
Caret has a special case for versions starting with a zero major: ^0.3.2 means >=0.3.2 <0.4.0, not <1.0.0 — because by semver convention, a 0.x version hasn't yet committed to API stability, so even the minor version position is treated as potentially breaking. This is a common source of confusion for packages still in 0.x development.
Tilde (~) and wildcard (*)
The tilde constraint ~1.2.3 is more restrictive than caret at the same precision: it allows patch-level updates only, meaning >=1.2.3 <1.3.0 — the rightmost specified digit is the one allowed to increase. But ~1.2 (without a patch component) is actually more permissive, becoming >=1.2.0 <2.0.0, equivalent to caret in that particular two-segment case. This makes tilde's behavior genuinely dependent on how many version segments you write, which is worth internalizing carefully rather than guessing.
The wildcard constraint uses * in place of a version segment: 1.2.* means >=1.2.0 <1.3.0 (equivalent to ~1.2.0), and 1.* means >=1.0.0 <2.0.0. Wildcards are intuitive to read but functionally redundant with tilde/caret in most cases — modern Composer style guides generally favor caret for its direct semver alignment and reserve tilde/wildcard for cases needing that specific patch-only or explicit-segment behavior.
• Exact: "1.4.2" — only that version • Range: ">=1.0,<2.0" — explicit boundaries, comma is logical AND • Caret: "^1.2.3" -> >=1.2.3 <2.0.0 (no breaking major bump) — the general recommendation • Caret on 0.x: "^0.3.2" -> >=0.3.2 <0.4.0 (minor treated as breaking pre-1.0) • Tilde: "~1.2.3" -> >=1.2.3 <1.3.0 (patch-only); "~1.2" -> >=1.2.0 <2.0.0 (minor allowed) • Wildcard: "1.2.*" -> >=1.2.0 <1.3.0; "1.*" -> >=1.0.0 <2.0.0
Stability flags, minimum-stability, and prefer-stable
Composer versions carry an implicit or explicit stability level: dev, alpha, beta, RC (release candidate), and stable, in ascending order of maturity. By default Composer only considers stable releases when resolving constraints. A per-package stability flag suffix overrides this for one requirement, e.g. "symfony/mercure-bundle": "^0.3@beta" explicitly allows beta releases for that package even though the project's overall minimum-stability might be stable.
The project-wide "minimum-stability" key in composer.json (e.g. "minimum-stability": "beta") lowers the floor for every package at once, allowing Composer to consider any release at or above that stability level, project-wide, unless a specific package's constraint says otherwise. Because a permissive minimum-stability can accidentally pull in unstable versions of packages you didn't mean to loosen, Composer also supports "prefer-stable": true — this tells the resolver that, among all versions satisfying a constraint at or above the minimum stability, it should prefer the most stable one available rather than always grabbing the newest (potentially less stable) one. The common, safe combination for a project that occasionally needs a beta dependency is minimum-stability: beta paired with prefer-stable: true, which opens the door to pre-release versions without making them the default choice everywhere else.
Transitive dependencies and constraint intersection
Real projects rarely hit version conflicts from their own direct requirements alone — conflicts almost always emerge transitively, when two different dependencies you require each pull in a third package with incompatible constraints. If your project requires package A which needs ^2.0 of a shared library, and also requires package B which needs ^1.0 of that same shared library, Composer's resolver must find a single version satisfying both simultaneously (the intersection of ^2.0 and ^1.0 is empty), and if it can't, composer require/composer update fails with a dependency resolution error rather than silently picking one and breaking the other.
This is exactly why loose, generous version ranges in library packages (discussed further in package authoring) matter for the whole ecosystem's health: a library that requires an unnecessarily narrow exact version of a common dependency makes itself much more likely to be the one causing an unresolvable transitive conflict for any application that also depends on something else needing a different version of that same shared package.
Constraint syntax reference
Prefer ^ (caret) as your default for application dependencies — it captures semver's non-breaking-update promise directly and is what composer require writes by default. prefer-stable: true almost always belongs alongside any minimum-stability looser than the default stable, or unstable versions can leak into unrelated packages' resolution.
{
"require": {
"vendor/exact-pin": "1.4.2",
"vendor/range": ">=1.0,<2.0",
"vendor/caret-normal": "^1.2.3",
"vendor/caret-zero-x": "^0.3.2",
"vendor/tilde-patch": "~1.2.3",
"vendor/tilde-minor": "~1.2",
"vendor/wildcard": "1.2.*",
"vendor/exclude-bad": "^2.0,!=2.3.1",
"vendor/beta-allowed": "^0.3@beta"
},
"minimum-stability": "beta",
"prefer-stable": true
}Code Examples
^3.0 allows any 3.x release from 3.0.0 up to (but not including) 4.0.0 — the standard way to say 'give me bug fixes and new backward-compatible features automatically, but never a breaking major version without me explicitly asking'. Expected result: Using version ^3.0 for monolog/monolog
Both use tilde, but adding or omitting the patch segment changes the meaning entirely: ~2.1.0 locks to the 2.1.x patch series only, while ~2.1 (no patch given) allows any 2.x version at or above 2.1 — this is the single most common tilde mistake, assuming both forms behave the same way.
Lowering minimum-stability to beta project-wide would, on its own, risk accidentally pulling pre-release versions of unrelated dependencies whenever a newer unstable release happens to satisfy their constraint too. Pairing it with prefer-stable: true keeps that door open only where genuinely needed (the one package with an explicit @beta flag) while every other package still resolves to its most stable eligible version.
"minimum-stability": "beta", "prefer-stable": true
composer require "monolog/monolog:^3.0"
# composer.json now contains: "monolog/monolog": "^3.0"
# accepts: 3.0.0, 3.4.1, 3.99.0
# rejects: 2.9.0 (lower major), 4.0.0 (higher major){
"require": {
"acme/legacy-client": "~2.1.0",
"acme/flexible-lib": "~2.1"
}
}
// acme/legacy-client (~2.1.0): allows 2.1.0, 2.1.9 — NOT 2.2.0
// acme/flexible-lib (~2.1): allows 2.1.0, 2.9.0 — NOT 3.0.0{
"require": {
"php": ">=8.1",
"acme/experimental-bundle": "^0.9@beta"
},
"minimum-stability": "beta",
"prefer-stable": true
}
// resolver behavior:
// - acme/experimental-bundle: beta releases explicitly allowed via @beta flag
// - every OTHER dependency: still resolves to the most stable version
// satisfying its constraint, because prefer-stable biases the whole
// resolution toward stability wherever a stable option existsCaret vs tilde ranges for 1.2.3
Caret always stops at the next breaking (major, or minor-if-0.x) boundary. Tilde's stopping point depends on how many version segments you wrote.
Version line: 1.0.0 1.2.0 1.2.3 1.3.0 2.0.0 3.0.0
^1.2.3: [=======================)
allows through <2.0.0
~1.2.3: [======)
allows through <1.3.0 (patch only)
~1.2: [=======================)
allows through <2.0.0 (same as ^1.2)Important Rules
^1.2.3 allows up to (not including) the next major version: >=1.2.3 <2.0.0.
^0.x.y treats the minor position as breaking too, since pre-1.0 packages haven't committed to a stable API: ^0.3.2 is >=0.3.2 <0.4.0.
~1.2.3 allows patch-only updates (<1.3.0); ~1.2 (no patch) allows minor updates too (<2.0.0) — the segment count changes the meaning.
Composer only resolves stable releases by default; use a per-package @stability flag or project-wide minimum-stability to allow dev/alpha/beta/RC versions.
Pair a loosened minimum-stability with prefer-stable: true so the resolver still prefers stable versions everywhere it can.
Where this shows up
composer require some/package writes a caret constraint by default precisely because it's the community-agreed safest default. Framework projects like Symfony ship 'flex' recipes and version-locking conventions built entirely around caret ranges tracking a target minor version (e.g. ^6.4), and large teams frequently run composer outdated combined with reading constraint ranges to decide which updates are safe to apply automatically versus which require manual review because they cross a major-version, breaking-change boundary.
"acme/some-lib": "~1.4.2" // intending to allow any 1.x release
"acme/some-lib": "^1.4.2" // or "~1.4" if patch-only truly isn't wanted
Writing the patch segment with tilde (~1.4.2) locks updates to the 1.4.x series only, blocking even backward-compatible 1.5.0 releases — a common mistake when the author actually wanted 'any compatible 1.x version', which is what caret (^1.4.2) or a patch-less tilde (~1.4) actually expresses.
[multiple-choice] What does the constraint "^0.4.2" allow? A. Any version from 0.4.2 up to but not including 1.0.0 B. Any version from 0.4.2 up to but not including 0.5.0 C. Only exactly version 0.4.2 D. Any version at all, 0.x is treated as unconstrained
B. Any version from 0.4.2 up to but not including 0.5.0 — Caret treats a zero major version specially: because 0.x releases haven't committed to API stability under semver, the minor position is treated as the breaking boundary too, so ^0.4.2 means >=0.4.2 <0.5.0, not <1.0.0.
[multiple-choice] Why would a project set both minimum-stability: beta and prefer-stable: true together, rather than just minimum-stability: beta alone? A. prefer-stable is required syntax and does nothing on its own B. Setting minimum-stability alone would make Composer refuse to install anything C. prefer-stable biases resolution toward the most stable version satisfying each constraint, preventing the loosened floor from accidentally pulling unstable versions of unrelated packages D. prefer-stable only affects require-dev packages, not require
C. prefer-stable biases resolution toward the most stable version satisfying each constraint, preventing the loosened floor from accidentally pulling unstable versions of unrelated packages — Lowering minimum-stability opens the door to pre-release versions project-wide; prefer-stable: true tells the resolver to still choose the most stable eligible version wherever one satisfies the constraint, so the lowered floor only actually matters for packages/versions that truly need it.
For each of these constraints, write out the equivalent >=/< range: (a) ^2.5.1, (b) ^0.4.0, (c) ~3.2.7, (d) ~3.2, (e) 1.9.* (a) >=2.5.1 <3.0.0 (b) >=0.4.0 <0.5.0 (c) >=3.2.7 <3.3.0 (d) >=3.2.0 <4.0.0 (e) >=1.9.0 <1.10.0
Remember caret stops at the next breaking boundary (major, or minor for 0.x); tilde's stopping point depends on whether a patch segment is present.