Composer 2 arrived on the twenty-fourth of October with a rewritten dependency solver and parallel downloads, and the numbers are large enough to be worth an upgrade on their own. It also changed the plugin API, which is where the work is.
The symptom
$ time composer install --no-dev
Loading composer repositories with package information
Installing dependencies from lock file
Package operations: 94 installs
real 1m31.204s
$ time composer update --dry-run
resolving dependencies through SAT
real 3m48.117s
PHP Fatal error: Allowed memory size of 1610612736 bytes exhaustedNinety seconds on every CI run and every container build, and an update that needs 1.5 gigabytes and sometimes does not finish. Both are Composer 1 characteristics rather than characteristics of this dependency tree.
Why it happens
Composer 1 downloads packages one at a time over one connection, and its solver loads the full metadata of every version of every package it might consider before deciding anything. On a tree of ninety-four packages that is a lot of JSON and a lot of round trips.
Composer 2 fetches in parallel using curl multi and loads package metadata lazily, so the solver only reads what the constraints actually reach. Neither is a tuning change — both required the rewrite.
The fix
Upgrading, and finding out what breaks first
# in a container, so nothing on the machine changes
$ docker run --rm -v "$PWD:/app" -w /app composer:2
validate --strict
./composer.json is valid
$ docker run --rm -v "$PWD:/app" -w /app composer:2
update --dry-run 2>&1 | tail -20
Plugin 'foo/installer' is not compatible with Composer 2.
Your requirements could not be resolved.Running it in a container against the real project is the whole first step, and it takes two minutes. The plugins are what fail and they fail immediately with a clear message, which makes the assessment quick.
{
"require": {
"composer/installers": "^1.9",
"johnpbloch/wordpress-core-installer": "^2.0"
},
"config": {
"platform": { "php": "7.4.11" },
"platform-check": false
}
}
The three plugins needing versions were all custom installers, all had Composer 2 releases within a fortnight of the launch, and the constraint bump was the entire change. A plugin without a compatible release is the case that blocks an upgrade outright, and there is no shim.
platform-check is new and defaults to on: Composer 2 generates a platform_check.php that aborts at autoload time if the running PHP does not satisfy the constraint. That is a good default and it is a fatal error in production for anybody whose build machine and runtime differ, so it is worth knowing about before the deploy rather than after.
The lock file, which changes format and stays compatible
$ git diff --stat composer.lock
composer.lock | 2841 ++++++++++++++++++-----------------
$ jq -r '."plugin-api-version"' composer.lock
2.0.0 # was 1.1.0
# Composer 1 can still READ a lock file written by 2 —
# but re-running install with 1 rewrites it back. so the
# version has to move everywhere at once:
# developer machines, CI, and the deployment image.The lock churn is large and is almost entirely reordering and format changes rather than version changes, which makes the diff useless for review. Regenerating it in a commit that does nothing else is what keeps the next diff readable.
The mixed-version problem is the real coordination cost. One developer on Composer 1 running composer require rewrites the lock file in the old format, and the next person on 2 rewrites it back — which is a churning diff and a confused pull request rather than a broken build.
{
"config": {
"platform": { "php": "7.4.11" }
},
"require": {
"composer-runtime-api": "^2.0"
}
}
Requiring composer-runtime-api: ^2.0 is the mechanism that makes the coordination enforceable rather than social: Composer 1 cannot satisfy it and refuses to run. That is a hard cut and it is much better than discovering the version mismatch through a lock file diff.
The Docker build, where most of the time was
FROM composer:2 AS vendor
WORKDIR /app
# the lock file alone, so the layer caches on it
COPY composer.json composer.lock ./
RUN composer install --no-dev --no-scripts --no-autoloader
--prefer-dist --no-progress
COPY . .
RUN composer dump-autoload --optimize --classmap-authoritative
Splitting install from autoload generation is what makes the vendor layer cache on the lock file rather than on the application source, so a source change does not reinstall ninety-four packages. That was true under Composer 1 too and it matters more now that the install is fast enough for the cache miss to be the dominant cost.
--classmap-authoritative is worth the flag in a production image: it removes the filesystem check for classes not in the map, which is a measurable saving on a request handling many classes and is wrong for anything generating classes at runtime.
What the numbers actually became
$ time composer install --no-dev
real 0m11.408s # was 1m31
$ time composer update --dry-run
real 0m22.902s # was 3m48, when it finished
$ /usr/bin/time -f '%M KB' composer update --dry-run
412008 KB # was 1.5 GB
# and the CI effect, which is the one people notice
# pipeline median: 4m12 → 2m38The install improvement is parallel downloads and is the one visible in CI. The update improvement is the new solver and matters much less often — it is the difference between a dependency change being a coffee break and being a command.
The memory figure is the one that changes what is possible: an update that needed 1.5 gigabytes could not run on the build container without a raised limit, which was a special case in the pipeline configuration that could now be removed.
Verifying it worked
$ composer --version
Composer version 2.0.2
$ composer validate --strict --no-check-publish
./composer.json is valid
$ composer diagnose
Checking composer.json: OK
Checking platform settings: OK
Checking git settings: OK
Checking disk free space: OK
$ vendor/bin/phpunit
Tests: 1,204 passed
# and the one that matters: the same packages, same versions
$ diff
<(jq -r '.packages[] | "(.name) (.version)"' composer.lock.bak | sort)
<(jq -r '.packages[] | "(.name) (.version)"' composer.lock | sort)
> composer/installers 1.10.0
< composer/installers 1.9.0Diffing the name-and-version pairs out of both lock files is what makes the enormous raw diff reviewable — it reduces two thousand eight hundred changed lines to the three packages that actually moved. That is worth keeping as a habit for any lock file regeneration.
composer diagnose is underused and checks the things that produce confusing failures later: a broken git configuration, a full disk, an unreachable repository. Running it once after the upgrade is thirty seconds.
What this costs
A coordinated version bump across every machine that touches the lock file, which on a team of eight is a message and a morning and on a larger team is a genuine rollout. The composer-runtime-api constraint makes it enforceable and also makes it abrupt — somebody who has not upgraded gets a refusal rather than a warning, which is the correct behaviour and needs saying in advance.
The plugin surface is the risk that does not have a general answer. Three plugins here all had releases ready, which is a function of the ecosystem having had months of warning. A project depending on an abandoned installer plugin has no upgrade path at all and has to replace it first, and that is not discoverable from the version numbers — only from running the dry run.