Josh Davies

Senior Software Engineer

I build beautiful, engaging, and perfectly executed frontend and backend experiences. While finding time to write, hike and read.

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.lock

On 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.json

npm 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 update on purpose, for a reason, and commit the new lockfile.
  • CI and deploys run install / ci. Never update.

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 ci

composer 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-package

Half 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 audit

Update 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.lock and package-lock.json. Yes, for apps. Always.
  • CI and deploys use composer install and npm ci. Never update.
  • 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.

← All posts