Composer deploy fails: memory exhausted, then "your php version does not satisfy"
Our deploy ran composer update on a 2 GB server until the resolver ran out of memory. Raising the limit let the OOM killer take MySQL instead. Committing the lock file exposed a second bug: it had been resolved on PHP 8.4 for a PHP 8.2 server.
We maintain a booking API for a group of small clinics: a Slim 4 application on a single 2 GB VPS,
deployed by a shell script that runs over SSH. One Monday the deploy for a minor fix failed half
way, with the new code checked out and the old vendor/ directory partly rewritten:
Loading composer repositories with package information
Updating dependencies
PHP Fatal error: Allowed memory size of 1610612736 bytes exhausted (tried to allocate 4096 bytes) in phar:///usr/local/bin/composer/src/Composer/DependencyResolver/RuleSetGenerator.php on line 64
Check https://getcomposer.org/doc/articles/troubleshooting.md#memory-limit-errors for more info on how to handle out of memory errors.
The deploy script's key line had not changed in three years:
git pull --ff-only && composer update --no-dev
Reproducing it
On a staging VM with the same size, composer update --no-dev failed the same way every time. With
/usr/bin/time -v it reported a maximum resident size just over 1.5 GB right before the crash.
On a developer laptop with 32 GB the same command succeeded, peaking at about 1.7 GB.
What we ruled out
- A low PHP
memory_limit. The number 1610612736 bytes is 1.5 GiB, which is the minimum Composer raisesmemory_limitto by itself. Our CLIphp.iniwas at 512M; Composer had already lifted it. - Composer 1.
composer --versionshowed 2.8.5. Composer 1 used far more memory, so this is worth checking on old servers. - A corrupted cache.
composer clear-cachechanged nothing.
Root cause
The repository's .gitignore contained composer.lock, so the server never had a lock file and
had to resolve the full dependency graph from scratch on every deploy. A month earlier we had added
the AWS SDK for PHP for S3 backups, and the resolver's working set crossed 1.5 GB on that graph.
The first attempted fix made things worse. Someone set COMPOSER_MEMORY_LIMIT=-1 in the deploy
script, the resolver grew to about 1.9 GB, and the kernel's OOM killer chose mysqld:
Out of memory: Killed process 812 (mysqld) total-vm:2209188kB, anon-rss:401152kB, file-rss:0kB, shmem-rss:0kB, UID:110 pgtables:1360kB oom_score_adj:0
The proper fix, committing a lock file and running composer install, then failed in CI with a
second, unrelated problem:
Installing dependencies from lock file (including require-dev)
Verifying lock file contents can be installed on current platform.
Your lock file does not contain a compatible set of packages. Please run composer update.
Problem 1
- phpunit/phpunit is locked to version 12.0.4 and an update of this package was not requested.
- phpunit/phpunit 12.0.4 requires php >=8.3 -> your php version (8.2.26) does not satisfy that requirement.
The developer had generated the lock on a laptop running PHP 8.4, where Composer happily picked PHPUnit 12, which needs PHP 8.3. CI and production run PHP 8.2.
What is happening under the hood
composer update and composer install do very different jobs:
| Command | Reads | Work | Memory |
|---|---|---|---|
composer update |
composer.json and package metadata from repositories |
Solves a SAT problem over every candidate version, writes composer.lock |
High, grows with the dependency graph |
composer install with a lock |
composer.lock |
Downloads the exact versions listed, verifies platform requirements | Low |
Running update on a production server means production resolves versions nobody tested, and
every deploy can pick something new. It also needs resolver-sized memory on the smallest machine you
own.
The second error is about the platform: Composer treats the PHP version and extensions as
packages too. When resolving, it uses the PHP that runs Composer, unless config.platform says
otherwise. A lock resolved on PHP 8.4 can legitimately contain packages that need 8.3 or 8.4. When
installing, Composer checks the lock against the real platform and refuses. If you bypass that with
--ignore-platform-reqs, Composer's generated vendor/composer/platform_check.php stops the app at
runtime instead:
Composer detected issues in your platform: Your Composer dependencies require a PHP version ">= 8.3.0". You are running 8.2.26.
Environment
| Component | Version / value |
|---|---|
| Server | Debian 12 VPS, 2 GB RAM, no swap, PHP 8.2.26 (FPM and CLI), MySQL 8.0 |
| Composer | 2.8.5 |
| App | Slim 4, Doctrine DBAL 4, AWS SDK for PHP 3 |
| Developer laptop | macOS, PHP 8.4 from Homebrew |
| CI | GitHub Actions, php:8.2-cli container |
| Lock file | Ignored in .gitignore until the fix |
Minimal reproduction
# On PHP 8.4:
composer init --name=demo/app --no-interaction
composer require --dev phpunit/phpunit # resolves ^12.0 on PHP 8.4
# On PHP 8.2, with the lock file copied over:
composer install
# phpunit/phpunit 12.0.x requires php >=8.3 -> your php version (8.2.x) does not satisfy that requirement.
Who is affected
Any PHP project where developers run a newer PHP than production, or where servers run
composer update. Small servers hit the memory error first; larger ones hide the risk of
unreviewed version changes. Projects using Laravel, Symfony or WordPress with Composer are all
affected the same way.
The fix
1. Tell Composer which PHP production runs. In composer.json:
{
"require": {
"php": "^8.2"
},
"config": {
"platform": {
"php": "8.2.26"
},
"sort-packages": true
}
}
With config.platform.php set, every composer update and composer require on any machine
resolves as if PHP 8.2.26 were running, so the lock only contains packages production can install.
2. Re-resolve the lock on a developer machine and review the diff:
composer update phpunit/phpunit --with-all-dependencies
git diff composer.lock | grep '"version"' | head
PHPUnit moved to 11.5.10, which supports PHP 8.2, and nothing else changed.
3. Commit the lock file. We removed composer.lock from .gitignore and committed it. For an
application (not a library), the lock is part of the source.
4. Build vendor/ in CI, not on the server. The pipeline now runs:
composer validate --strict
composer install --no-interaction --prefer-dist --no-progress
vendor/bin/phpunit
composer install --no-dev --no-interaction --prefer-dist --optimize-autoloader --classmap-authoritative
composer check-platform-reqs --no-dev
tar -czf release.tar.gz --exclude=.git .
The deploy script uploads the tarball, unpacks it into a new release directory and switches a
symlink, so the server never runs Composer at all. Peak memory for composer install from the lock
was about 90 MB in CI.
Alternatives and trade-offs
COMPOSER_MEMORY_LIMIT=-1. Correct advice for a developer laptop or a CI runner with room. On a small production server it only moves the failure to the OOM killer, as we learned.- Add swap. Lets
updatefinish slowly, and keeps hiding the real problem of resolving in production. --ignore-platform-reqs. Useful in narrow cases, for example installing tooling in a container that lacks an extension used only at runtime elsewhere. As a general fix it replaces a clear install error with a runtime failure.- Run the same PHP version locally. Ideal, using containers. We still keep
config.platformbecause it protects anyone who forgets.
How to test the fix
# In the php:8.2-cli container used by CI:
composer install --no-interaction
composer check-platform-reqs
vendor/bin/phpunit
check-platform-reqs prints each requirement with success or failed, and exits non-zero if
anything does not match, which makes it a good final gate before packaging.
Confirming in production
The first deploy with the new pipeline took 41 seconds end to end, down from about four minutes when
the server resolved dependencies. Memory on the VPS stayed under 1.1 GB during the switch, and MySQL
was not touched. We compared composer show --locked output from CI with the server's
vendor/composer/installed.json, and they listed the same 64 packages and versions.
Regression guard
- CI runs
composer validate --strict, which fails ifcomposer.lockis out of date withcomposer.json. - The PHP version in
config.platform, the CI container tag and the server's packages are checked together by a small script that fails if they disagree on the minor version. - The deploy script refuses to run if it finds
composerin its own commands, so nobody reintroducescomposer updateon the server.
Security implications
Running composer update in production means any newly published version, including a compromised
one, could be installed without review. A committed lock file pins versions and their dist
checksums, and building in CI lets us run composer audit before anything ships. Removing Composer
from the server also removes the need for registry credentials there. Treat
--ignore-platform-reqs with suspicion in pipelines; it can install code that has never run on your
PHP version.
Prevention checklist
- Commit
composer.lockfor every application. - Set
config.platform.phpto the production PHP version. - Run
composer installfrom the lock in CI; nevercomposer updateon servers. - Gate releases on
composer validate --strict,composer check-platform-reqsandcomposer audit. - Keep developer, CI and production PHP versions aligned and checked automatically.
References
- Composer documentation, Troubleshooting: memory limit errors — https://getcomposer.org/doc/articles/troubleshooting.md#memory-limit-errors
- Composer documentation, Config:
platform— https://getcomposer.org/doc/06-config.md#platform - Composer documentation, Basic usage: commit your lock file — https://getcomposer.org/doc/01-basic-usage.md#commit-your-composer-lock-file-to-version-control
- Composer documentation, Command-line interface (
check-platform-reqs,install,audit) — https://getcomposer.org/doc/03-cli.md - PHPUnit, supported versions and PHP compatibility — https://phpunit.de/supported-versions.html
Add a label
Embed this post
<iframe src="https://hibug.me/composer-memory-exhausted-platform-requirements" width="700" height="420" frameborder="0" loading="lazy"></iframe>
I have spent most of my career on the side of software that pages people at night: payment integrations, web platforms with real traffic, CI/CD pipelines, Kubernetes clusters and the databases underneath them. Somewhere along the way I started keeping a notebook of every bug that cost more than an hour — the exact error, what we ruled out, the real root cause and how we proved the fix. Hibug is where those notes live. Every post follows the same path: Investigate, Explain, Fix, Verify — with the environment spelled out, the fix tested, and the security angle checked.
Comments
0No comments yet. Be the first to comment!
Sign In to post a comment.