Skip to content
intermediate Phase 8 · Composer and Autoloading

Version Constraints and Stability

Pin dependency ranges and understand stability flags.

40m
0 problems
Topic Progress 0%

^1.2.3 or ~1.2.3: Reading Composer's Constraint Grammar

A pull request adds "monolog/monolog": "^2.0" to composer.json, and the reviewer has to answer one question: what did this just allow? The scripts from Running Composer Scripts will cheerfully run composer update all day without telling you whether this range is the one the team meant — that judgment is yours, and every constraint in a manifest is a range in disguise. Reading the range is the whole skill; the operators are only mnemonics for it.

The full grammar, with the ranges it expands to:

Constraint Actually means Use it when
1.0.2 exactly 1.0.2 you must control the version and will hand-fight conflicts
^1.2.3 >=1.2.3 <2.0.0 semver promises hold — the default for libraries
^0.3.2 >=0.3.2 <0.4.0 the package is pre-1.0, so the minor is breaking
^0.0.3 >=0.0.3 <0.0.4 anything can change on any digit
~1.2.3 >=1.2.3 <1.3.0 you want patches only
~1.2 >=1.2 <2.0.0 you name a floor minor and accept the rest of 1.x
1.0.* >=1.0 <1.1 you are pinning a minor line explicitly
1.0 - 2.0 >=1.0.0 <2.1 a bounds range — note the partial right bound widens

Two rows deserve a second look. The caret blocks the first digit that signals a breaking change: from 1.0 up that is the major, but for 0.x packages nothing has been declared stable, so Composer treats the minor as the breaking digit — ^0.3.2 will not give you 0.4.0. That is the answer interviewers are waiting for when they ask about ^0.0.3. The tilde is looser in a different direction: only the last digit you wrote may rise, so ~1.2.3 stops at 1.3.0 while ~1.2 (no patch) opens all the way to 2.0.0, and ~1 behaves like ~1.0.

Beyond the operators, comparisons compose: >=1.0 <1.1 || >=1.2 is a real constraint, where a space (or comma) means AND and || means OR — with AND binding tighter, so >=1.0 <1.1 || >=1.2 groups as (>=1.0 <1.1) || >=1.2. The single | still parses as OR for backward compatibility. Unbounded ranges like >=1.0 are legal and dangerous: Composer will happily hand you the next major, which is exactly what the caret exists to prevent.

Applying one is a CLI concern too:

composer require monolog/monolog:^2.0      # shell-safe on Linux and macOS
composer require monolog/monolog:"^2.0"    # quote it when the shell has opinions
composer update monolog/monolog            # re-resolve ONE package inside its range

On Windows PowerShell the caret is an escape character and must be written as ^^^^1.2.3 to reach Composer intact — a bug that presents as a nonsense version error and wastes an afternoon the first time you hit it.

The habit that makes reviews fast is expanding the shorthand before agreeing to it. ^2.4 reads as >=2.4.0 <3.0.0, so a 3.0 with a rewritten API is excluded by policy rather than by luck; ~2.4 is >=2.4.0 <2.5.0, which quietly forgoes every 2.5 feature while still looking permissive in the diff. When a resolution surprises you, two commands turn the guesswork into a lookup:

composer why monolog/monolog       # which of your requirements pulled it in
composer prohibits monolog/monolog 2.0   # what blocks an upgrade to 2.x

Notice what composer update monolog/monolog does and does not do: it picks the highest version satisfying the constraint you already declared and rewrites the lock for that package only. The constraint is the policy; the lock is the outcome — a split that the next two chapters treat directly, starting with what happens when a constraint refuses to resolve at all.

Stability Flags: When Composer Refuses Your dev-main Dependency

Add a branch constraint to a fresh project and watch Composer's behavior change:

composer require acme/widget:dev-main

The constraint itself is fine — branch references are spelled dev-main, and a branch that looks like a version is spelled with a .x in it (v1.x-dev, 1.0.x-dev) so Composer reads it as a branch rather than a tag. The problem is the stability ladder underneath. Composer ranks every version it sees:

Rank Stability How it appears
lowest dev any dev-* branch, 1.0.x-dev, commits
alpha 1.1.0-alpha3
beta 1.1.0-beta1
RC 1.1.0-RC1
highest stable 1.1.1 — a plain tag with no suffix

Your root composer.json declares a floor with minimum-stability, which defaults to stable. Every requirement is filtered against that one floor, so with the default in place a dev-main requirement on a transitive dependency can be skipped entirely — Composer considers only stable versions, finds none, and your install fails with a resolution problem rather than an obvious "you forgot a flag." Raising minimum-stability to dev project-wide "fixes" it and quietly lets an unstable alpha of any dependency into your lock file. That is the trade the question is really about.

The surgical answer is a stability flag on one constraint:

{
    "minimum-stability": "stable",
    "prefer-stable": true,
    "require": {
        "acme/widget": "@dev",
        "monolog/monolog": "1.0.*@beta"
    }
}

The grammar is constraint@stability: "monolog/monolog": "1.0.*@beta" admits beta-and-above for that package, and a bare "acme/widget": "@dev" is the documented way to allow a dev-only dependency — including the case where another package requires it at dev-master and you must require it yourself to lift the flag. Everything else stays behind the stable floor. prefer-stable: true then tells the solver to pick a stable version whenever one satisfies the constraints, so the flags widen what is allowed without widening what is preferred.

Two more details that show up in review:

{
    "require": {
        "acme/widget": "dev-main",
        "acme/other": "dev-main#1a2b3c4d5e6f7890"
    }
}
  • A #<commit> suffix pins a dev package to a commit. Composer's own documentation marks it as a temporary workaround with severe limitations: the solver never sees that commit, the package's own metadata is read from the branch head, and source installs only. The supported pinning mechanism is the lock file, covered next.
  • Version-like branches need the .x spelling — 1.0.x-dev means the 1.0 branch, while dev-1.0 would be a branch literally named dev-1.0. Getting this backwards produces "could not be found" errors that look like stability problems and are not.

If you want to see which version a constraint would pick before committing to it, Composer's docs point at a constraint tester — paste ^1.2 and watch every matching release highlight. The habit worth building is the same one you use with code: expand the shorthand to its real range before you accept the diff.

Two habits keep the stability policy honest over time:

composer outdated --direct   # which of your own ranges have newer matching releases
composer update --prefer-lowest   # resolve every requirement to the OLDEST version allowed

--prefer-lowest is a library's tool: it installs the floor of each constraint, which is how you discover that ^1.2 promises 1.2.0 while your code actually needs 1.4 — the promise in the manifest should be one your package can keep. And when a dev branch does land, the lock records it as "version": "dev-main" plus the exact commit reference, so composer install keeps handing you that same commit until somebody deliberately runs update again. Stability rules only bite at resolution time; after that, the lock is the source of truth.

One Developer Ran composer update: The composer.lock Contract

The symptom is classic. A teammate adds a feature, runs composer update because "it was complaining," and commits both files. CI installs, production installs, and somewhere between them a transitive dependency released a new minor with a breaking change — the same composer.json now resolves differently on every machine. Nothing in the constraints was wrong. The contract that was broken is the lock file's.

Two files, two jobs:

File Holds Who writes it Who reads it
composer.json ranges, scripts, autoload rules humans, deliberately the solver, on composer update
composer.lock exact versions, source references, content-hash Composer every composer install

composer install does not resolve anything. It reads the lock and downloads exactly what it names, in an operation Composer reports as Lock file operations: N installs, M updates, K removals. composer update re-runs the solver against every range in the manifest and rewrites the lock. That difference is why CI must run install: an update in CI means your build depends on whatever Packagist served that morning, and reproducing a production failure locally becomes a matter of luck.

composer install                      # replay the lock — CI, deploys, every clone
composer update acme/widget           # re-resolve one package inside its range
composer update                       # re-resolve everything; review this diff
composer validate && composer audit   # manifest sanity + known advisories vs the lock

The workflow that keeps this honest: edit composer.json on purpose, run the narrowest composer update that expresses your intent, and commit the lock with the change that required it. A lock diff with no manifest change is a dependency bump and deserves its own review; a manifest change with no lock diff means the install never ran. When someone edits only the manifest, Composer warns that the lock is out of date with composer.json — that warning is a real bug in the branch, not noise to --ignore.

Applications commit the lock; libraries usually do not, because a library's consumers bring their own lock and your resolved tree would only fight theirs. For the same reason, avoid the #<commit> shortcut for pinning — Composer documents its limitations bluntly (the solver never sees the pinned commit, and the package's metadata is still read from the branch head), and it is exactly the kind of pin the lock file already performs correctly.

require-dev rides along in the same lock. A deploy runs composer install --no-dev, replaying identical versions for runtime packages while skipping PHPUnit and static analysers; CI runs composer install with dev packages to execute the suite. Both read one locked tree, so "works in CI, fails in production" cannot come from different dependency versions.

Environments need pinning too, and Composer separates the two concerns:

{
    "config": {
        "platform": { "php": "8.2.11", "ext-redis": "6.0.2" }
    }
}

platform fakes the PHP and extension versions during resolution, so a developer on 8.3 still resolves what production on 8.2 can install. composer check-platform-reqs is the counterweight: it verifies the real runtime (CI's actual PHP, actual extensions) rather than the configured fiction, which is why it belongs in the deploy script and platform belongs in the manifest. Together they stop the classic failure where the lock is identical everywhere but only some machines can actually run it.

You now have a manifest whose ranges, stability floor, and lock agree — the Composer half of the stack is done. The application you deploy next has to reach its database on every request, and that starts with Connecting with PDO.

Quiz

1. A dependency declares "^0.3.2". Which versions satisfy that constraint?

Question 1 options

2. You write ~1.2.3 in one require and ^1.2.3 in another. What is the real difference?

Question 2 options

3. The rest of your project must keep resolving stable releases, but one package is only published as dev-main. What do you change?

Question 3 options

4. CI started installing different dependency versions than production after a teammate ran composer update locally. What is the fix?

Question 4 options

Flashcards

Question

What range does ^1.2.3 admit, and how does that change for ^0.3.2?

Answer

^1.2.3 is >=1.2.3 <2.0.0 — the major digit is the breaking boundary. For 0.x the minor is treated as breaking instead: ^0.3.2 is >=0.3.2 <0.4.0, and ^0.0.3 is >=0.0.3 <0.0.4. Same operator, different digit carrying the compatibility promise.

Question

What does the tilde let rise, and how does it differ from caret?

Answer

Only the last digit you specify: ~1.2.3 is >=1.2.3 <1.3.0 (patches), ~1.2 is >=1.2 <2.0.0, and ~1 behaves like ~1.0. Caret instead follows semver and blocks the breaking digit, so ^1.2.3 spans the whole 1.x line. Caret is the recommended default for libraries.

Question

How do you take a dev-only dependency without letting the whole project float below stable?

Answer

Attach a stability flag to that requirement — "acme/widget": "@dev" or "pkg": "1.0.*@beta" — while minimum-stability stays stable (its default). prefer-stable: true then still prefers releases when both stabilities match. Stability order is dev, alpha, beta, RC, stable.

Question

What does composer.lock guarantee that composer.json cannot?

Answer

The lock records exact versions, source references (commit hashes), and a content hash of composer.json. composer install replays it byte-for-byte on every machine and in CI; composer update is the only command that re-resolves and rewrites it, which is why an application commits the lock and a library normally does not.

Question

Translate these: 1.0.*, 1.0 - 2.0, >=1.0 <1.1 || >=1.2.

Answer

1.0.* is a wildcard: >=1.0 <1.1. 1.0 - 2.0 is a hyphen range with a partial right bound, so the 2.0 becomes 2.0.* and it means >=1.0.0 <2.1 (while 1.0.0 - 2.1.0 means >=1.0.0 <=2.1.0). Space-separated comparisons AND together, || (or the legacy |) ORs them, and AND binds tighter.

Revision Notes

Key Takeaways

  • 1. Caret blocks the breaking digit: ^1.2.3 is <2.0.0, but 0.x treats the minor as breaking so ^0.3.2 is <0.4.0
  • 2. Tilde lets only the last specified digit rise — ~1.2.3 is <1.3.0, ~1.2 is <2.0.0 — which makes it the tighter operator at the same spelling length
  • 3. minimum-stability sets one floor for the whole project (default stable); per-package @dev or @beta flags widen it for a single dependency
  • 4. composer.json holds ranges, composer.lock holds the resolved tree: install replays, update re-resolves — an update is a code review artifact, not a routine chore
  • 5. On Windows PowerShell a caret must be escaped as ^^^^1.2.3 or the shell eats it before Composer sees the constraint

Interview Tips

  • • "What does ^1.2.3 mean?" — give the range (>=1.2.3 <2.0.0), then volunteer the 0.x case; the zero-major trick is how interviewers check that you know the operator rather than the slogan
  • • "install or update in CI?" — install, always: it replays composer.lock; update belongs to a human who runs it deliberately and commits the rewritten lock with the change that needed it
  • • "A transitive dependency only exists as dev-master — what now?" — require it explicitly with an @dev stability flag in the root manifest instead of flipping minimum-stability to dev for every package, and keep prefer-stable on
  • • "How do you freeze versions without going stale?" — a caret range plus a committed lock; narrow to one package with composer update vendor/pkg so each lock diff is reviewable in isolation

Cheat Sheet

Version Constraints and Stability

Translate before you accept:

Constraint Equivalent range Note
1.0.2 exactly 1.0.2 solver fails if another requirement disagrees
^1.2.3 >=1.2.3 <2.0.0 semver default for libraries
^0.3.2 >=0.3.2 <0.4.0 0.x: the minor is the breaking digit
~1.2.3 >=1.2.3 <1.3.0 only the last digit written may rise
~1.2 >=1.2 <2.0.0 two digits → up to the next major
1.0.* >=1.0 <1.1 wildcard minor line
1.0 - 2.0 >=1.0.0 <2.1 partial right bound is completed with *
>=1.0 <1.1 || >=1.2 space = AND, || = OR, AND binds tighter | still parses as OR

Stability: dev < alpha < beta < RC < stable · minimum-stability (root-only) floors the whole project, default stable · widen one package with constraint@stability → "acme/widget": "@dev", "monolog/monolog": "1.0.*@beta" · prefer-stable: true keeps stable preferred · branches: dev-main, version-like branches as 1.0.x-dev · PowerShell needs ^^^^1.2.3.

Lock file:

composer install                  # replay composer.lock — CI and deploys
composer update acme/widget       # re-resolve one package in its range
composer update                   # re-resolve all; review + commit the lock
composer validate && composer audit

Applications commit composer.lock; libraries normally do not. config.platform pins resolution to production's PHP/extensions, composer check-platform-reqs verifies the real runtime instead.

Continue Learning

Before You Start

Next Topics

Related Reading