Every guide to composer.lock tells you the same thing: commit it, and every environment that runs composer install against it gets identical dependency versions. That's true as far as it goes. What it doesn't tell you is what happens when one environment's actual installed state quietly stops matching the file that's supposed to describe it — because nobody updated the lock file from that environment, they just changed things on it directly.
That's what I found on this site. Local's composer.lock said drupal/core-recommended 10.3.2. Production was running 10.4.6. That's a real, meaningful version gap, and it wasn't the only one — local's lock file was also missing packages that production had installed entirely: drupal/classy, drupal/stable, drupal/showcase_lite, drupal/google_tag. Not older versions of them. Absent. Local's composer.lock didn't know these packages existed, while production's database had modules from all four enabled and doing real work.
What that costs you
The expensive part isn't the version mismatch itself, it's what it looks like while you're debugging something else. Earlier in the same session, I'd been chasing what looked like "missing module code" problems — config referencing things that weren't behaving the way they should locally. Every one of those investigations was really the same root cause wearing a different hat: local literally didn't have the code for modules that config sync expected to find, because the lock file that was supposed to guarantee reproducibility across environments had drifted out from under itself. I was debugging symptoms of an environment mismatch as if they were application bugs, on a codebase where the actual bug was one directory up, in a file I hadn't thought to double check because it was committed and therefore, I assumed, correct.
That assumption is the actual failure. A committed lock file only guarantees reproducibility for the environments that were actually built from it going forward, from that point on. It says nothing about an environment that already existed before the file was last regenerated, and it says nothing about an environment where someone — or some automated process — installed or updated a package directly, without that change ever making it back into a commit. Production had drifted ahead of local. It could just as easily drift the other way, or diverge on both sides independently, and composer.lock would keep insisting, correctly by its own logic, that everything was fine, because nothing had changed it recently enough to notice.
The fix, and why it wasn't "git pull"
Fixing this wasn't a matter of resolving a merge conflict or bumping a version number. I pulled production's actual composer.json and composer.lock directly and ran composer install locally against them, so local's dependency tree became a byte-for-byte match of what's really running rather than an approximation reconstructed from whatever local's own history implied it should be. That also surfaced a second, smaller drift: production runs PHP 8.4.17, and several packages in the freshly synced lock file require 8.4. Local's DDEV config was still pinned to 8.3. Bumped it to match — a one-line config change, but one that would have produced its own confusing failure mode the next time a package genuinely needed an 8.4-only feature and local had no way to know it was even out of date on the interpreter, let alone the dependencies.
The actual lesson
A lock file is a snapshot, not a subscription. It guarantees that anyone running composer install against it right now gets the same result; it says nothing about whether "right now" is still an accurate description of what your other environments are currently running. The discipline that keeps a lock file meaningful isn't committing it once and trusting git to keep it honest; it's periodically checking that the file and the live environment it's meant to describe still agree, especially on any system where changes can land through a path other than "edit code, commit, deploy." Production is exactly that kind of system — module installs and config changes there don't automatically write themselves back into a pull request. If they don't, the gap between what the lock file claims and what's actually installed only grows, silently, until something forces you to go looking for it directly instead of trusting the file to have already told you.