DevOps & CI/CD Bug Report

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.

Daniel Tran Daniel Tran @fun_dev · February 27, 2025 · 8 min read · 13 Views
Banner: a Composer dependency resolver overflowing server memory next to a PHP version mismatch

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 raises memory_limit to by itself. Our CLI php.ini was at 512M; Composer had already lifted it.
  • Composer 1. composer --version showed 2.8.5. Composer 1 used far more memory, so this is worth checking on old servers.
  • A corrupted cache. composer clear-cache changed 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

  1. 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.
  2. Add swap. Lets update finish slowly, and keeps hiding the real problem of resolving in production.
  3. --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.
  4. Run the same PHP version locally. Ideal, using containers. We still keep config.platform because 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 if composer.lock is out of date with composer.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 composer in its own commands, so nobody reintroduces composer update on 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.lock for every application.
  • Set config.platform.php to the production PHP version.
  • Run composer install from the lock in CI; never composer update on servers.
  • Gate releases on composer validate --strict, composer check-platform-reqs and composer audit.
  • Keep developer, CI and production PHP versions aligned and checked automatically.

References

Add a label

#php #ci #deployment #composer #phpunit
Written by
Daniel Tran @fun_dev

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.

Advertisement

Comments

0

No comments yet. Be the first to comment!

Sign In to post a comment.