skip to content

In a Composer library's composer.json, how do you declare support for both major versions 1 and 2 of a dependency, and what mistakes break consumers?

level: seniorimportance: should knowfreq 30%

answer

  1. || is OR, space or comma is AND
  2. AND binds tighter than OR
  3. no unbounded >= ranges
  4. exact pins block consumers' solvers
  5. test both lowest and highest

basics

~20 s

Write an OR constraint such as "acme/serializer": "^1.8 || ^2.0", with each lower bound the oldest release you test. Avoid >=1.8, which also accepts 3.0, and exact pins, which leave consumers' solvers no room; test the lowest and highest resolutions in CI.

solid answer

~40 s

Composer's constraint grammar has `||` for **OR** and a space or comma for **AND**, with AND binding tighter, so `"^1.8 || ^2.0"` accepts 1.8 up to, not including, 3.0 while documenting both majors. Each lower bound should be the oldest release you actually test. The mistakes are: an **unbounded** `>=1.8`, which silently admits 3.0; an **exact** version, which makes every consumer's solver fail as soon as anything else needs another version; and a range you never exercise. `composer validate` warns about unbound and exact constraints in `require`. In CI, run one job with `composer update --prefer-lowest --prefer-stable` and one with the newest versions, and use `--with acme/serializer:^1.8` to force the old major. Where the APIs differ, branch at runtime with `Composer\InstalledVersions::satisfies()` or feature checks.

code

bash · 5 lines
bash
composer validate --strict
composer update --prefer-lowest --prefer-stable
vendor/bin/phpunit
composer update --with acme/serializer:^1.8
vendor/bin/phpunit

go deeper

for a junior

Recall that || means OR in a Composer constraint, so two caret ranges can be combined in one require entry.

for a middle

Explain AND-over-OR precedence, why >=1.8 is unbounded, and why an exact pin in a library breaks consumers' resolution.

for a senior

Prove a multi-major range in CI with --prefer-lowest and a forced old major, and isolate API differences behind one adapter.

for a principal

Decide how long to straddle two majors, weighing consumers who cannot upgrade against the cost of testing and maintaining both paths.

## Why libraries face this and applications rarely do An application picks one version of each dependency and freezes it in its lock file. A **library** cannot: its `require` entries are merged with those of every other package in the consumer's project, and the solver must find a single version that satisfies all of them. If your library demands `^2.0` of a serializer while another library the consumer uses still needs `^1.8`, the consumer cannot install both. Supporting **both majors** for a transition period is how a library avoids becoming the blocker. ## The constraint grammar that expresses it Composer's constraints combine with three pieces of syntax: - a **space** or **comma** means logical **AND**: `>=1.8 <2.0`; - a **double pipe** `||` means logical **OR**: `^1.8 || ^2.0`; - **AND has higher precedence than OR**: `>=1.0 <1.1 || >=1.2` reads as `(>=1.0 <1.1) || (>=1.2)`. A single pipe `|` is still accepted as OR for backward compatibility with old Composer releases, but `||` is the documented form. There is also a hyphenated inclusive range, `1.8 - 2.4`, which completes a partial right side with a wildcard. ```json { "require": { "php": "^8.2", "acme/serializer": "^1.8 || ^2.0" }, "conflict": { "acme/serializer": "2.1.0" } } ``` This reads: any 1.x from 1.8, or any 2.x, but never 3.0. The `conflict` entry excludes one known-broken release without narrowing the range; note that ranges inside `conflict` also need `||` when you mean OR, because `<1.0 >=1.1` there means both at once and matches nothing. ## Mistakes that break consumers | Constraint | What goes wrong | |---|---| | `>=1.8` | unbounded; the day 3.0 ships, consumers get it and your code may break | | `*` | the same, with no lower bound either | | `2.0.4` | exact; any consumer needing 2.0.5 for a security fix cannot resolve | | `^1.8 \|\| ^2.0` but tested only on 2.x | the 1.x path is untested and rots | | `^1.0 \|\| ^2.0` with 1.8 features used | the lower bound lies; `--prefer-lowest` users hit missing methods | `composer validate` flags the first three kinds in `require`: it warns that unbound version constraints should be avoided, and that exact constraints should be avoided if the package follows semantic versioning. `--no-check-all` silences those warnings, which is rarely what a library wants. ## Proving the range in CI A declared range is a promise; CI has to exercise both edges. 1. **Lowest**: `composer update --prefer-lowest --prefer-stable` installs the oldest versions your constraints allow, which catches a lower bound set too low. 2. **Highest**: a normal `composer update` installs the newest allowed, the 2.x line here. 3. **Force the old major**: `composer update --with acme/serializer:^1.8` narrows the constraint temporarily for one run without editing `composer.json`; the temporary constraint must be a subset of the declared one. Libraries usually do not commit a lock file, so these runs reflect what consumers will actually get. ## Reading a range the way the solver does Before committing a multi-major constraint, expand it by hand. `^1.8 || ^2.0` becomes `>=1.8.0 <2.0.0` or `>=2.0.0 <3.0.0`, which is continuous from 1.8 to just below 3.0. Writing it as `>=1.8 <3.0` would accept the same versions, but the two-caret form documents intent: each major is a separately supported line, and a reviewer can see at a glance which ones. When you later add support for 3.0, append `|| ^3.0`; when you drop 1.x, delete the first caret. Neither edit needs anyone to reason about bounds again. ## Handling API differences in code When major 2 renamed or removed something, the library needs a branch at runtime. Two options: - **Feature detection**: `method_exists()` or `class_exists()` on the API that changed. It is precise and has no dependencies. - **Version check**: `\Composer\InstalledVersions::satisfies(new VersionParser(), 'acme/serializer', '^2.0')`, which requires `composer/semver` and also returns true when another package `provide`s or `replace`s the name. Keep the branches in one adapter class, so dropping 1.x later is a single deletion plus a constraint change to `^2.0`, released as a new major of your own library if consumers could still depend on 1.x through you.

  • How does Composer read the constraint >=1.0 <1.1 || >=1.2?
    AND binds tighter than OR, and a space means AND, so it is `(>=1.0 <1.1) || (>=1.2)`: 1.0.x releases, or anything from 1.2 upward, which excludes 1.1. The right side is unbounded, so `composer validate` would warn about it in a library's `require`.
  • When you drop support for major 1, is changing the constraint to ^2.0 a breaking change for your own library?
    It can be. A consumer still stuck on 1.x of the dependency could install your old release but not the new one, and a patch or minor update of your library would suddenly fail to resolve for them. Many maintainers therefore drop an old dependency major only in a new major of their own library.

saying these in an interview costs you the question

  • A single pipe is required for OR in Composer constraints
  • >=1.8 is a safe way to support 1.x and 2.x
  • OR binds tighter than a space in a Composer constraint
  • Pinning an exact version in a library makes consumers more stable
  • composer validate accepts unbound constraints in require without any warning