Composer Lockfile Deploys: Commit the Lock, Install in Production, Pin the Platform
Commit composer.lock, run composer install (never update) in production, and pin config.platform.php — the workflow, a worked composer.json, and the mistakes that cause drift.
27 Jul 2026, 12:15 UTC

The decision that prevents production drift
The single most consequential Composer habit is this: commit composer.lock to version control, run composer install on servers and in CI, and treat composer update as a deliberate, reviewed operation you run locally. Everything else in this article is the mechanism that makes that habit work.
The two commands answer different questions. composer update reads the version constraints in composer.json, resolves them against the package repository, and writes the exact winning versions into composer.lock. composer install ignores the constraints' freedom entirely and installs precisely what the lock records. If you run update on a server, every deploy can silently pull different package versions depending on what was released that day. If you run install against a committed lock, every environment builds the same dependency tree, byte for byte.
A worked composer.json
Here is a representative project file. The version numbers illustrate constraint syntax; check Packagist for current stable releases before copying them into a real project.
{
"name": "acme/billing-api",
"require": {
"php": "^8.2",
"monolog/monolog": "^3.5"
},
"require-dev": {
"phpunit/phpunit": "^10.5"
},
"autoload": {
"psr-4": {
"Acme\\Billing\\": "src/"
}
},
"config": {
"platform": {
"php": "8.2.20"
}
}
}
Run these from the project root, as your normal development user (avoid running Composer as root, since install scripts execute package code):
composer update # resolves, writes composer.lock
git add composer.json composer.lock
git commit -m "Lock dependencies for PHP 8.2.20"
On the server or in the CI build stage, from the same project root:
composer install --no-dev --optimize-autoloader
--no-dev excludes require-dev packages, so PHPUnit and similar tooling never reach the production image. --optimize-autoloader (equivalent to composer dump-autoload -o) converts PSR-4 rules into a static class map, avoiding filesystem lookups per class. Verify the result with composer show on the server and compare versions against the lock in your repository — they must match exactly. Exact flag names vary between Composer releases, so confirm with composer help install on your installed version.
Why config.platform.php matters
The config.platform.php entry makes the resolver simulate that PHP version, regardless of the PHP actually running Composer. Without it, a developer on PHP 8.4 can run composer update and lock packages that require 8.4 features; the lock then installs fine but the application fatals on the 8.2 production server. With the pin, the resolver refuses any package version whose PHP requirement exceeds 8.2.20, failing at update time — where you can see it — instead of at runtime.
You can also pin extensions with "ext-redis": "5.3.7"-style entries in the same block. The important limit: this shapes resolution only. It does not install PHP, check the server's extensions, or guarantee the runtime matches. You still need infrastructure-level assurance (container base images, provisioning) that production actually runs the pinned version.
Constraint syntax: where updates go wrong
Because update re-resolves within your constraints, the constraint notation defines your blast radius. Under semver, ^3.5 means >=3.5.0 <4.0.0 — the next update may jump minor versions. ~3.5 means >=3.5.0 <3.6.0 — patch releases only. Confusing the two is a common cause of "surprise" upgrades. For libraries where minor releases have historically broken you, tighten to ~ or an exact version.
Autoloading in production
Composer generates vendor/autoload.php from your autoload field. Two production optimizations are common: --optimize-autoloader builds the class map at install time, and setting "classmap-authoritative": true under config makes the class map the sole source — any class not in it fails to load. The authoritative mode is faster but unforgiving: new classes require a fresh dump-autoload, so it suits deployed artifacts, not active development checkouts.
Limits and common mistakes
- The lock pins packages, not the runtime. PHP itself, extensions, and OS libraries are outside Composer's control. A successful
installdoes not prove the app will boot. - The lock says nothing about vulnerabilities. Composer 2.4+ ships
composer audit, which checks locked packages against the Packagist security advisories database. Run it in CI on every lock change; confirm availability withcomposer help audit. - Never edit files under
vendor/. It is generated output; the next install or update silently discards your patch. Fork the package or use a patches plugin instead. - Committing composer.json without the lock turns every deploy back into a fresh resolution — the drift problem returns.
- Major-version mismatch. Composer 1.x and 2.x differ in resolver behavior and lock format. A lock from one major may need regeneration with the other; standardize the Composer version in your build image and check it with
composer --version.
To validate the whole workflow before trusting it: in a scratch clone, run composer install, then composer show, and diff the versions against the original checkout. Identical output means your lockfile pipeline is doing its job.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.