Blog
Commit the Lockfile. Then Actually Use It.
- php
- laravel
- typescript
- node
- deployment
- dependencies
"Works on my machine" is usually not a mystery. Most of the time it's two machines running different code and nobody noticed, because the difference lives in vendor/ or node_modules/.
The fix has been sitting in the repo the whole time. It's the lockfile. The problem is that a lot of teams commit it and then go out of their way not to use it.
The manifest is a wish list
composer.json and package.json don't say what you're running. They say what you'd be willing to run.
{
"require": {
"laravel/framework": "^11.0",
"guzzlehttp/guzzle": "^7.8"
}
}^11.0 means "anything from 11.0 up to, but not including, 12.0." That's a range, not a version. Run an install on Monday and again on Friday and you can get two different trees if something got released in between. Same for every transitive dependency you've never heard of.
composer.lock and package-lock.json are the receipt. Exact versions, exact sources, for the whole tree. That's the thing you tested. That's the thing that should ship.
Install is not update
This is where it usually goes sideways. The two commands look similar and do very different jobs.
composer install # read composer.lock, install exactly that
composer update # resolve ranges again, rewrite composer.lockOn npm it's the same split with worse naming:
npm ci # install exactly what package-lock.json says, fail if it doesn't match package.json
npm install # resolve and possibly rewrite package-lock.jsonnpm ci also wipes node_modules first, which is exactly what you want on a build server. No leftovers from last week.
So the rule is boring:
- Developers run
updateon purpose, for a reason, and commit the new lockfile. - CI and deploys run
install/ci. Neverupdate.
If your deploy script says composer update, your production dependencies are decided by whatever got tagged on Packagist that morning. That's not a deploy. That's a raffle.
Make CI refuse a drifted lockfile
The common failure is someone edits composer.json by hand, forgets to update the lock, and opens a PR. Catch that before it merges.
# .github/workflows/ci.yml (excerpt)
- name: Composer
run: |
composer validate --strict
composer install --no-interaction --prefer-dist --no-progress
- name: Node
run: npm cicomposer validate complains when the lock file is out of date with composer.json, and --strict turns warnings into a failing exit code. npm ci already errors out when the lock and the manifest disagree. Using pnpm or Yarn? pnpm install --frozen-lockfile and yarn install --immutable are the same idea.
Pin the runtime too
The lockfile only protects you if it was resolved against the same PHP and Node you run in production. Resolve on PHP 8.4 locally, deploy to 8.2, and Composer can happily lock a package your server can't run.
Tell Composer what production looks like:
{
"config": {
"platform": {
"php": "8.2.0"
}
}
}Now composer update resolves as if you're on 8.2, no matter what's on your laptop.
On the Node side, put the version in .nvmrc and have the build check it instead of trusting everyone to remember:
// scripts/check-node.ts
import { readFileSync } from "node:fs";
const wanted = readFileSync(".nvmrc", "utf8").trim().replace(/^v/, "");
const wantedMajor = wanted.split(".")[0];
const actualMajor = process.versions.node.split(".")[0];
if (wantedMajor !== actualMajor) {
console.error(`Expected Node ${wanted}, got ${process.versions.node}`);
process.exit(1);
}Small, dumb, and it saves you an afternoon of "why does the build pass locally."
Know what you're actually running
When something breaks after a dependency change, the first question is "what version is in production right now." Guessing from the manifest doesn't answer that. The lockfile does, and so does the app itself.
In PHP, Composer 2 ships a runtime class for exactly this:
use Composer\InstalledVersions;
$version = InstalledVersions::getPrettyVersion('guzzlehttp/guzzle');
Log::info('Dependency versions', [
'laravel' => InstalledVersions::getPrettyVersion('laravel/framework'),
'guzzle' => $version,
]);Laravel also gives you php artisan about, which prints the framework, PHP version, and environment details in one shot. Handy to run on a box when someone swears it's "the same."
And when you need to know why a package is in the tree at all:
composer why vendor/some-package
npm explain some-packageHalf the time the answer is "a package you installed three years ago pulled it in, and you've never touched it." Good to know before you start blaming your own code.
Updates are a task, not an accident
None of this means freeze everything forever. That's how you end up two majors behind with a security advisory you can't patch without a rewrite.
It means updates happen on purpose:
composer outdated --direct
composer update guzzlehttp/guzzle --with-dependencies
composer audit
npm outdated
npm auditUpdate one thing (or one small group), run the tests, read the changelog for anything major, commit the lockfile diff, and open a PR that says what changed. Tools like Dependabot or Renovate will open those PRs for you. Either way, the lockfile change shows up in review where someone can see it, instead of showing up in production where someone feels it.
Merge conflicts in the lockfile
They happen. Don't hand-edit your way out of them.
For Composer, take one side's composer.lock, then rerun the specific composer update vendor/package the other branch made so the lock gets regenerated properly. For npm, resolve package.json first and run npm install to regenerate the lock. Either way, commit the result and let CI check it.
A lockfile someone "fixed" by deleting the conflict markers is worse than no lockfile, because now it looks trustworthy.
The short version
- Commit
composer.lockandpackage-lock.json. Yes, for apps. Always. - CI and deploys use
composer installandnpm ci. Neverupdate. - Fail the build when the lock drifts from the manifest.
- Pin the PHP and Node versions you resolve against.
- Update dependencies on purpose, in small PRs, with the lockfile diff visible.
The lockfile is the only file in the repo that tells the truth about what your app runs on. Let it.