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?
answer
- || is OR, space or comma is AND
- AND binds tighter than OR
- no unbounded >= ranges
- exact pins block consumers' solvers
- test both lowest and highest
basics
~20 sWrite 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 sComposer'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 linescomposer validate --strict
composer update --prefer-lowest --prefer-stable
vendor/bin/phpunit
composer update --with acme/serializer:^1.8
vendor/bin/phpunitgo deeper
Recall that || means OR in a Composer constraint, so two caret ranges can be combined in one require entry.
Explain AND-over-OR precedence, why >=1.8 is unbounded, and why an exact pin in a library breaks consumers' resolution.
Prove a multi-major range in CI with --prefer-lowest and a forced old major, and isolate API differences behind one adapter.
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