The Lock File and Versions
composer.lock records the exact version you got, which is why it is committed and why install and update are different commands.
"monolog/monolog": "^3.9" → >=3.9.0 <4.0.0, pinned by composer.lock to one releasecomposer.json says what you will accept. composer.lock says what you got. The constraint ^3.9 matches many releases; the lock file names one, 3.10.2, and records its commit hash, plus the same for every package your packages pulled in behind the scenes.
Why it is committed
Commit composer.lock for every application. It is what makes composer install reproducible: your laptop, the CI runner and the server all end up byte-identical, and a bug you cannot reproduce is never a dependency that quietly moved. A library published for others to depend on commits it too, but consumers ignore it — only the root project's lock file is used.
Reading a constraint
| Constraint | Means |
|---|---|
3.10.2 | exactly that release |
^3.9 | >=3.9.0 and <4.0.0 |
^0.3.1 | >=0.3.1 and <0.4.0 |
~3.9.1 | >=3.9.1 and <3.10.0 |
>=3.9 | no upper bound; avoid |
The caret is the default because it relies on semantic versioning: a package promises that the major number changes when something breaks. Below 1.0.0 nothing is stable yet, so ^ treats the minor number as the breaking one.
What trips people up
Editing composer.json by hand does not touch the lock file, and composer install then refuses, saying the lock file is out of date. The fix is composer update vendor/package for the one package you changed — not a bare composer update, which moves everything at once.
Example
When to use it
- Guarantee that CI, your laptop and production run the identical dependency versions.
- Upgrade one package without dragging every other dependency forward with it.
- Work out why a dependency refuses to move to a newer major version.
- Resolve a composer.lock merge conflict without corrupting the file.
More examples
What asked-for and got look like side by side
Two things to read here. First, psr/log appears in the lock file although you never asked for it: it is a transitive dependency, pulled in by Monolog, and the lock pins it too. Second, content-hash is a fingerprint of the require sections of composer.json. When you edit that file by hand the hash stops matching and composer install reports that the lock file is out of date, which is the mechanism behind that error rather than a guess.
// composer.json - what you will ACCEPT
{
"require": {
"php": ">=8.5",
"monolog/monolog": "^3.9"
}
}
// composer.lock - what you GOT (trimmed; the real file has more fields)
{
"content-hash": "9f1c0a4b...",
"packages": [
{
"name": "monolog/monolog",
"version": "3.10.2",
"source": {
"type": "git",
"url": "https://github.com/Seldaek/monolog.git",
"reference": "0c1b1c0..."
},
"require": {
"php": ">=8.1",
"psr/log": "^2.0 || ^3.0"
}
},
{
"name": "psr/log",
"version": "3.0.2"
}
],
"packages-dev": []
}Moving one dependency on purpose
This is the routine for a controlled upgrade. --dry-run prints the plan and writes nothing, which is how you see the blast radius before agreeing to it. why-not answers the common puzzle where a package refuses to move and the real blocker is a third package requiring an older version of a shared dependency. The last line exists for exactly one case: you changed composer.json in a way that does not affect installed versions and only the hash needs rewriting.
# What do I actually have installed?
composer show monolog/monolog
# What would move if I updated, without touching anything?
composer update --dry-run
# Why can this package not go higher? Names what is holding it back.
composer why-not monolog/monolog 4.0
# Move only this package (and what it needs), leave the rest pinned.
composer update monolog/monolog --with-dependencies
# Accept the new major version: edit the constraint, then update that package.
composer require monolog/monolog:^4.0
# Lock file hash stale after editing composer.json by hand, versions fine.
composer update --lockThe lock file as the contract a deployment honours
The deployment installs from the lock file and never resolves versions itself, so the artefact on the server matches what CI tested. Keep composer update out of any script that runs on a server, including a Dockerfile. The validate line at the end is cheap insurance: it catches the case where someone edited composer.json, committed it, and forgot to commit the regenerated composer.lock alongside.
#!/usr/bin/env bash
set -euo pipefail
# On the server. Note what is NOT here: composer update.
git fetch --all
git checkout --detach "$RELEASE_SHA"
# --no-dev skip require-dev (no test runner in production)
# --prefer-dist use the packaged archive, not a git clone
# --no-progress quiet output for the deploy log
composer install --no-dev --prefer-dist --no-progress --optimize-autoloader
# Fails the deploy if composer.lock is missing or out of date with
# composer.json, rather than installing something nobody tested.
composer validate --no-check-publish
Discussion