Composer Lock File and Reproducible Builds: Hashes, Platform Config, CI
AI generated
<?php
8.4
PHP · Composer · Package Ecosystem
Composer Lock File and Reproducible Builds
why composer.lock always belongs in the repository

A deployment that works on a developer's machine but fails in production with a different package version can usually be traced back to a missing or mishandled composer.lock file. Anyone who understands how Composer builds hashes, how platform config influences resolution, and how merge conflicts arise in the lock file, builds PHP applications that install exactly the same package versions in every environment.

18 min read composer.lock · Hash Verification · Platform Config PHP 8.4 · Composer 2.x

1. Why composer.lock exists and what it guarantees

The composer.lock file solves a fundamental problem that every version constraint in composer.json inevitably brings with it: a declaration such as mironsoft/core ^2.0 allows any version from 2.0.0 up to but excluding 3.0.0. Without a lock file, composer install would re resolve the currently latest matching version from that range on every invocation, meaning a developer might install version 2.3.1 today while a colleague already gets 2.4.0 tomorrow, without composer.json ever having changed at all.

The composer.lock file fixes exactly this resolution: it contains, for every installed dependency, the exact version, the exact commit hash, and a checksum hash, so that composer install, once a lock file exists, never runs version resolution again but installs only the versions pinned there. Only composer update deliberately ignores the existing lock file and computes a fresh resolution based on the current version constraints in composer.json.

This distinction is the core of reproducible builds in PHP: composer install with an existing lock file guarantees the same package versions on a developer's laptop, a CI runner and a production server, regardless of when and where the command runs. Without this guarantee, every deployment would be a gamble in which a meanwhile released, incompatible patch version of a dependency could silently land in production.

2. Structure of the lock file: what actually gets stored

A composer.lock file is plain JSON and at its core contains two arrays: packages for production dependencies and packages-dev for development dependencies. Every entry in there stores not just name and version, but also the exact source, usually a Git repository with a concrete commit hash, and the dist download location, typically a zip archive of the respective tag provided by Packagist.

In addition, the lock file contains a platform field that documents the PHP version and extension requirements relevant at resolution time, as well as a plugin-api-version field that records the Composer version at the time of the last update. This metadata explains why a very old lock file, generated with a significantly older Composer version, occasionally produces warnings on the first composer install with a newer Composer version.


{
  "_readme": [
    "This file locks the dependencies of your project to a known state",
    "read more about it at https://getcomposer.org/doc/01-basic-usage.md#composer-lock-the-lock-file"
  ],
  "content-hash": "b1946ac92492d2347c6235b4d2611184",
  "packages": [
    {
      "name": "mironsoft/core",
      "version": "2.3.1",
      "source": {
        "type": "git",
        "url": "https://github.com/mironsoft/core.git",
        "reference": "a1b2c3d4e5f6789012345678901234567890abcd"
      },
      "dist": {
        "type": "zip",
        "url": "https://api.github.com/repos/mironsoft/core/zipball/a1b2c3d4e5f6789012345678901234567890abcd"
      }
    }
  ],
  "platform": {
    "php": "^8.4"
  },
  "platform-dev": [],
  "plugin-api-version": "2.6.0"
}

3. Hash verification: content hash and package hash

The content-hash at the beginning of every composer.lock file is a checksum over the relevant parts of composer.json, in particular require, require-dev and the version constraints. Composer compares this hash on every invocation against a freshly computed checksum of the current composer.json. If the two differ, Composer reports that the lock file is no longer up to date and that a composer update might be required before composer install continues without a warning.

In addition to the content-hash, Composer verifies the integrity of every single package during download through a SHA hash of the archive stored in the lock entry. This check happens regardless of whether the source is Packagist, a private registry or a path repository, and prevents a tampered or corrupted archive from being installed unnoticed. A hash mismatch causes composer install to hard fail, rather than silently continuing with potentially compromised code.


# Verify that composer.lock is in sync with composer.json without installing anything
composer validate --no-check-publish --strict

# Explicitly check whether the lock file's content-hash matches composer.json
composer install --dry-run

# If the hash mismatches, Composer prints a warning like:
# Warning: The lock file is not up to date with the latest changes in composer.json.

4. Platform config: pinning down the PHP version and extensions

A subtle but practically relevant aspect of reproducible builds is the platform configuration in composer.json, not to be confused with the platform field in the lock file itself. Through config.platform.php a specific PHP version can be enforced, regardless of which PHP version is actually installed on the machine where composer update runs. That matters when a developer uses a newer PHP version locally than the production server does.

Without an explicit platform configuration, Composer resolves dependencies based on the actually installed local PHP version and extensions. If a developer resolves a new dependency locally with PHP 8.4 that uses a function only introduced in PHP 8.3, while the production server still runs PHP 8.2, composer install unexpectedly fails in production, even with an identical composer.lock file. An explicit platform declaration ensures that Composer bases every resolution on the same target PHP version, regardless of the locally installed one.


{
  "config": {
    "platform": {
      "php": "8.2.99",
      "ext-redis": "6.0.2"
    },
    "sort-packages": true
  }
}

5. composer install versus composer update in deployment

Only composer install --no-dev --optimize-autoloader belongs in the production path of any deployment script, never composer update. The reason lies directly in the purpose of reproducible builds: composer install exclusively reads the existing composer.lock file and installs exactly the versions pinned there, while composer update deliberately performs a completely new version resolution and can thereby unintentionally pull in a new, untested package version in production.

The --no-dev option additionally skips every packages-dev entry such as PHPUnit or PHPStan, which are neither needed in production nor should be present there, both for security reasons and to reduce installation size. --optimize-autoloader generates a class map instead of the standard PSR-4 directory lookup, which measurably speeds up autoloading in production, especially for projects with several hundred classes.


# Deployment step — reproducible, no dependency resolution happens here
composer install --no-dev --optimize-autoloader --no-interaction --no-progress

# NEVER run this in a deployment pipeline, it re-resolves versions:
# composer update --no-dev

# Verify the lock file matches composer.json before deploying (fails the build if not)
composer validate --strict

6. Resolving merge conflicts in composer.lock

Merge conflicts in the composer.lock file inevitably arise as soon as two feature branches simultaneously add or update different dependencies. Because the file is JSON with deeply nested arrays, manually merging the Git conflict markers almost always leads to a syntactically invalid or semantically inconsistent file, even when the merge looks clean at first glance.

The correct approach is to never merge composer.lock by hand. Instead, composer.json from both branches gets cleanly merged, the composer.lock file gets discarded entirely, and composer update --lock, which only recomputes the lock file without actually installing packages, generates a fresh, consistent lock file. This approach avoids any form of manual JSON editing on a file that should be treated as a generated artifact in the first place.


# When composer.lock has merge conflicts after resolving composer.json manually:
git checkout --theirs composer.json   # or manually merge composer.json requirements
rm composer.lock

# Regenerate the lock file only, without installing packages
composer update --lock

git add composer.json composer.lock
git commit -m "Resolve composer.lock conflict by regenerating"

7. Securing reproducibility in CI pipelines

A CI pipeline should abort the build immediately when composer.json and composer.lock no longer match, instead of silently using an outdated lock file or unnoticeably performing a new resolution. composer install --no-dev combined with a preceding composer validate --strict reliably catches this case, before the actual install step even begins.

For reproducible builds across multiple CI runs, it is also worth caching Composer's download directory, not the vendor directory itself, keyed by a hash of the composer.lock file as the cache key. If composer.lock does not change, the cache delivers identical downloads without renewed network access to Packagist, which noticeably shortens build times without compromising reproducibility compared to a fresh composer install.

8. Versioning composer.lock: exceptions and special cases

For applications, the rule is clear: the composer.lock file always belongs in the Git repository, no exception. Only this guarantees that composer install produces the same package versions on every machine. A widespread but incorrect assumption is to add composer.lock to .gitignore in order to avoid merge conflicts. The result is exactly the opposite of the intended effect: without a versioned lock file, every developer and every CI run potentially installs different package versions, precisely the problem composer.lock is meant to solve.

The only documented exception concerns pure libraries, meaning Composer packages of type library that get pulled in as a dependency by other projects. For libraries, the official Composer documentation recommends not versioning composer.lock, because the actually used dependency versions are determined by the consuming project anyway, and a lock file of the library itself has no practical effect. For applications that actually get deployed, however, versioning the lock file remains mandatory.

9. composer install vs. composer update compared directly

The table below summarizes when each command is used and what effect it has on reproducible builds.

Aspect composer update composer install Practical relevance
Reads composer.lock Ignores the existing lock file Uses only the lock file Decisive for reproducibility
Version resolution New resolution on every call No re resolution Prevents unintended updates
Where it's used Locally, deliberately when needed CI, deployment, production Deployment always uses install
Updates composer.lock Yes, writes a new lock file No, the lock file stays unchanged Controlled updates needed
Speed Slower, full resolution Faster, no SAT solver computation Relevant for CI runtime

In practice this table means a clear separation of responsibilities: composer update is a deliberate, supervised action, usually triggered locally by a developer and reviewed in its own pull request, while composer install is the only command that should ever appear in a CI pipeline or a deployment script, to guarantee reproducible builds across every environment.

Mironsoft

PHP architecture, deployment strategy and Composer tooling

Deployments that install the same package versions in every environment?

We review your deployment pipeline for risky composer update calls, set up platform config and hash validation, and ensure consistent, reproducible builds from local to production.

Pipeline Audit

Analysis of your existing CI and deployment scripts for reproducibility

Configuration

Platform config, hash validation and merge conflict workflow cleanly set up

CI Optimization

Caching strategy for Composer downloads without endangering reproducibility

10. Summary

The composer.lock file is the foundation of reproducible builds in PHP: it pins exact versions, commit hashes and checksums for every dependency, so that composer install guarantees the same packages get installed in every environment. The content-hash detects deviations between composer.json and the lock file, while platform config in composer.json ensures that version resolution always considers the same target platform, regardless of the locally installed PHP version.

For deployment and CI, the strict rule is: composer install --no-dev --optimize-autoloader, never composer update. Merge conflicts in the lock file are not resolved manually, but regenerated through composer update --lock after composer.json has been cleanly merged. And the single most important rule of all: the composer.lock file belongs in the Git repository for every application, no exception, only pure libraries are exempt.

Composer Lock File and Reproducible Builds — The Essentials at a Glance

Hash Verification

content-hash checks composer.json against the lock file, every package is additionally verified through an archive hash.

Platform Config

config.platform.php pins the target PHP version during resolution, regardless of the locally installed version.

Deployment Command

composer install --no-dev --optimize-autoloader in production, never composer update in a deployment script.

Merge Conflicts

Never merge composer.lock manually, instead composer update --lock after a clean composer.json merge.

11. FAQ: Composer Lock File and Reproducible Builds

1What exactly does composer.lock do?
It pins version, source and checksum of every dependency, so composer install installs the same versions everywhere.
2What is the content-hash?
A checksum over relevant parts of composer.json. If it differs, Composer reports a possibly needed update.
3Why install instead of update in deployment?
install uses only the lock file, update resolves anew and can unintentionally install untested versions.
4What is platform config for?
Enforces a specific PHP version during resolution, regardless of the developer's locally installed version.
5How do I resolve merge conflicts?
Never manually, merge composer.json, delete composer.lock and regenerate it with composer update --lock.
6Should composer.lock always be in the repo?
For applications, yes, no exception. Only pure libraries usually don't version composer.lock.
7What happens on a hash mismatch?
composer install hard fails instead of continuing silently, protecting against tampered or corrupted archives.
8What does --no-dev do?
Skips all development dependencies such as PHPUnit, which are neither needed nor safe to have in production.
9How do I speed up installs in CI?
By caching the download directory keyed by a hash of composer.lock, without losing reproducibility.
10How do I check both files are in sync?
With composer validate --strict, which checks the content-hash and fails the CI immediately on mismatch.