A PHP deploy ended up running a different version of a Composer package than CI tested; which Composer workflow mistakes cause this, and how do you prevent them?
answer
- lock missing or ignored
- update on the server
- CI updated, deploy installed
- text-merged lock files
- build once, validate in CI
basics
~20 sSomething resolved versions instead of replaying one lock: an uncommitted composer.lock, a deploy or CI script running composer update, or a badly merged lock. Commit the lock, run composer validate in CI, and deploy with composer install --no-dev.
solid answer
~50 sProduction and CI can only disagree if one of them **resolved** versions instead of replaying the same lock. The common ways that happens: `composer.lock` is in `.gitignore` or never committed, so the deploy's `composer install` warns that no lock is present and resolves the newest versions; a deploy script runs `composer update` or `composer require` on the server; CI runs `composer update` "to test fresh dependencies" while the deploy installs the older committed lock; or a lock merge conflict was resolved by hand or by a full `update` that nobody reviewed. Prevention: commit the lock, have CI run `composer validate --strict` (a stale lock is an error) and `composer install`, deploy with `composer install --no-dev`, and ideally build the artifact once and ship the same `vendor/` that passed the tests. `--no-dev` itself is safe: runtime packages keep the versions CI resolved.
code
bash · 7 lines# CI
composer validate --strict
composer install --no-interaction
vendor/bin/phpunit
# deploy (same commit)
composer install --no-dev --no-interactiongo deeper
Know that the deploy must run composer install with a committed composer.lock, never composer update.
Explain which commands resolve versions and which replay the lock, and why --no-dev does not change runtime versions.
Trace a version drift to its cause from deploy logs and lock diffs, and harden the pipeline with validate, install-only stages and build-once artifacts.
Own the release contract: one tested artifact per commit, dependency changes only through reviewed lock updates, and no resolution on production hosts.
## The invariant Composer gives you one guarantee: `composer install` with the same `composer.lock` installs the same package versions everywhere. Production can differ from CI only if that invariant was broken somewhere, which means someone **resolved** versions (ran the solver) instead of **replaying** the lock, or the two environments replayed **different** locks. ## How it breaks | Cause | What actually happened | |---|---| | `composer.lock` not committed | the deploy's `install` found no lock, printed `No composer.lock file present`, and resolved the newest versions | | deploy runs `composer update` | the server re-resolved at deploy time; whatever was newest that minute shipped | | CI runs `composer update` | CI tested fresh resolutions; the deploy installed the older committed lock | | lock edited or merged by hand | the committed lock no longer matches what anyone tested | | a hotfix ran `composer require` on the server | `composer.json` and the lock on the server diverged from git | A subtle variant: a branch was merged where both sides touched `composer.lock`, and someone "fixed" it by running a full `composer update`. Composer's merge guide warns that the exact versions one branch had locked are lost at that point; the newest compatible versions come in untested. ## What is not a cause - **`--no-dev`.** Composer 2 resolves `require` and `require-dev` together during `update` and records both in the lock. `install --no-dev` omits the dev-only packages but installs the runtime packages at the versions CI had. A class that exists in CI but not in production is a misplaced `require-dev` entry, not a version difference. - **A stale `content-hash` alone.** `install` warns but still installs the locked versions, so CI and production still match each other; they just may not match `composer.json`. ## Prevention in the pipeline 1. **Commit `composer.lock`** in every application, and never list it in `.gitignore`. 2. **CI checks the lock first**: `composer validate --strict` fails on a lock that is out of date with `composer.json`; then `composer install`, never `update`, before the tests. 3. **Deploy with `composer install --no-dev --no-interaction`**, never `update` or `require`. Autoloader optimisation flags belong on the same command; they do not change versions. 4. **Build once, deploy the artifact.** The strongest guarantee is that the image or archive CI tested, `vendor/` included, is exactly what production runs, so no second install happens at all. 5. **Updates are code changes.** Dependency updates arrive as a commit that changes `composer.lock`, reviewed and tested like any other change, whether a developer or an update bot makes it. ## Diagnosing after the fact - Compare the lock the deploy used with the one CI used: `git diff <ci-sha> <deploy-sha> -- composer.lock`. - On the server, `composer show vendor/package` prints the installed version, and `vendor/composer/installed.json` records what was installed; compare it with the version in the lock. - Check the deploy log for `No composer.lock file present` or `Updating dependencies`, the lines that show resolution happened on the server. - If a text merge is suspected, `composer validate` reports required packages that are missing from the lock, and `composer install --dry-run` shows what an install would change. ## A worked incident A team's deploy script ran `composer install --no-dev` inside a container build. The build context excluded `composer.lock` through a broad ignore pattern meant for local files. Every deploy printed the no-lock warning, resolved the newest versions, and passed its smoke test. Weeks later a minor release of a date library changed a default, and invoices printed with the wrong timezone in production only. CI had been testing the committed lock the whole time. The fix was one line in the ignore file; the lasting fix was a build step that **fails** when `composer.lock` is absent, plus comparing `composer show` output from the image with the lock as part of the release checklist. ## Why this is worth an interview question The failure is quiet. Nothing errors at deploy time; a minor release with a behaviour change simply ships without having passed a single test. Candidates who have lived through it describe the invariant and the pipeline rules, not just "commit the lock".
- The deploy log shows 'No composer.lock file present. Updating dependencies to latest instead of installing from lock file.' What do you check first?Whether `composer.lock` is committed at all, then `.gitignore` and any build-context ignore file that might exclude it from the image. The message means `install` fell back to resolving fresh versions, so the deploy shipped whatever was newest at that moment rather than the tested set.
- Is composer install --no-dev a reason production could get different runtime package versions than CI?No. Composer resolves `require` and `require-dev` together during `update` and records both in the lock, so `--no-dev` only drops the dev-only packages. The runtime packages are installed at exactly the locked versions CI used.
saying these in an interview costs you the question
- composer install --no-dev resolves a separate set of versions for production
- Running composer update in the deploy script ensures production has the tested versions
- A stale content-hash makes production install different versions from CI
- composer.lock only matters on developer machines, not in CI or production
- Resolving a composer.lock merge conflict with a full composer update keeps the tested versions