The go-to resource for learning PHP, Laravel, Symfony, and your dependencies.

Lock File Strategy for Stable Upgrades


We’ve all been there: a deployment fails in production not due to code changes, but because dependency versions drifted between environments. This frustrating scenario can cause downtime, security vulnerabilities, or subtle bugs that evade testing. In the PHP ecosystem, Composer provides a powerful solution through its two-file approach: composer.json declares what you need, while composer.lock freezes exactly what you got.

Getting this lock file strategy right goes beyond being a best practice—it’s fundamental to building software that behaves consistently from development through production. Let’s examine how these files work together and establish patterns that will save you from deployment headaches.

The Role of composer.json

Your composer.json file serves as a blueprint for your project’s dependencies. It declares which packages your application needs—and what version ranges are acceptable. When we specify a constraint like ^7.4 for a package, we’re telling Composer: “Install any version from 7.4.0 up to, but not including, 8.0.” This flexibility lets us receive bug fixes and new features without manual intervention.

{
    "require": {
        "monolog/monolog": "^2.0"
    }
}

This approach has real benefits during active development. However—and this is crucial—the same flexibility that keeps us up-to-date also introduces uncertainty. If we run composer install today versus three months from now, we might get different versions—versions that could include subtle breaking changes that only surface in production.

One may wonder: why not pin every dependency to an exact version in composer.json? We could, but that would defeat the purpose of using a dependency manager. The two-file system separates concerns: composer.json expresses intent, while composer.lock captures reality.

What is the composer.lock File?

The composer.lock file provides the stability missing from flexible version constraints. It’s a complete snapshot—a definitive record—of the exact versions of every package and sub-package in your dependency tree. When we first run composer install, Composer resolves all dependencies based on composer.json, downloads them, and writes the precise versions into this lock file.

From that point forward, every composer install on any machine—your colleague’s laptop, the CI/CD pipeline, or production servers—installs the identical versions. This creates deterministic builds, which are essential for reliable deployments.

Tip: This approach isn’t unique to PHP. Node.js has package-lock.json, Python’s pip uses requirements.txt with pinned versions (or Pipfile.lock with pipenv), Rust has Cargo.lock, and Ruby’s Bundler generates Gemfile.lock. The universal principle: separate declaration from resolution, then capture the resolved state.

The lock file includes more than your direct dependencies—it captures every transitive dependency, all the way down the tree. If monolog/monolog requires a specific version of psr/log, that exact version is recorded too.

The Golden Rule: Commit composer.lock to Version Control

Here’s the most critical rule—and interestingly, it’s where many teams stumble: always commit your composer.lock file to your Git repository.

Before we explain why this matters, let’s address a common point of confusion. One may wonder: if composer.lock exists, does composer.json matter anymore? The answer is both. When we commit the lock file, we create a single source of truth for the exact versions deployed. But composer.json remains essential—it’s what allows us to upgrade intentionally later.

By committing composer.lock, we guarantee that every developer, every CI/CD runner, and every production server installs the same dependency tree. This eliminates the entire class of “works on my machine” bugs caused by version drift. The code you tested locally will behave identically in production because it’s running the same dependencies—not only the same version ranges, but the exact same packages at the exact same versions.

From a workflow perspective: when composer.lock is present, running composer install ignores composer.json and uses the locked versions exclusively. We’ll discuss how to update that lock file safely in the next section.

The Right Way to Upgrade Dependencies

If composer.lock freezes your dependencies, how do you actually upgrade them? The key is to do it intentionally—never automatically on production servers.

The correct command for upgrading is composer update. Here’s what happens when we run it:

  1. Composer reads composer.json and determines the latest versions allowed by our constraints
  2. It resolves the entire dependency graph, selecting compatible versions
  3. Composer updates packages in the vendor directory
  4. Most importantly, it regenerates composer.lock with the newly resolved versions

Let’s see this in action. Suppose we have this constraint in our composer.json:

{
    "require": {
        "laravel/framework": "^10.0"
    }
}

Running composer update laravel/framework might produce output like:

Loading composer repositories with package information
Updating dependencies
Lock file operations: 1 install, 0 updates, 0 removals
  - Upgrading laravel/framework (10.8.0 => 10.10.0)
Writing lock file
Installing dependencies from lock file
Package operations: 1 install, 0 updates, 0 removals
  - Installing laravel/framework (10.10.0)

Notice the “Writing lock file” step—that’s the crucial part. Our composer.lock now records version 10.10.0. If composer.lock didn’t change, the upgrade didn’t happen.

The safe upgrade workflow is deliberate:

  1. Run composer update <package-name> to upgrade a specific package, or composer update to check all dependencies. We prefer the targeted approach for better control.
  2. Run your test suite thoroughly. Automated tests catch regressions; manual testing verifies user-facing behavior.
  3. Commit both composer.json and composer.lock together in a single commit. This commit represents a tested, verified upgrade.

Walkthrough: Upgrading Guzzle HTTP Client

Let’s walk through this process with a real example. Suppose your project uses Guzzle for HTTP requests—currently version 6.x—and you want to upgrade to 7.x to get the latest features and security patches. Here’s how we’d do it safely:

Step 1: Check current state

First, let’s see what we have:

$ composer show guzzlehttp/guzzle
name     : guzzlehttp/guzzle
descrip. : Guzzle is a PHP HTTP client library
versions : * 6.5.8

Step 2: Review the upgrade guide

Before upgrading, we check Guzzle’s upgrade documentation—version 7.x includes breaking changes. We need to verify our code handles the changes (for example, Guzzle 7 drops PHP 5 support and uses PSR-7 interfaces differently).

Step 3: Update the package

We update only this package while keeping others locked:

$ composer update guzzlehttp/guzzle
Gathering patches to disable…
Loading composer repositories with package information
Updating dependencies
Lock file operations: 0 installs, 1 update, 0 removals
  - Upgrading guzzlehttp/guzzle (6.5.8 => 7.8.1)
Writing lock file
Installing dependencies from lock file
Package operations: 0 installs, 1 update, 0 removals
  - Upgrading guzzlehttp/guzzle (6.5.8 => 7.8.1)

Step 4: Verify the lock file

Let’s confirm the lock file reflects the new version:

$ grep -A2 '"name": "guzzlehttp/guzzle"' composer.lock
    {
        "name": "guzzlehttp/guzzle",
        "version": "7.8.1",

Step 5: Run tests

We execute our test suite. Any failures need investigation—we may need to update our code to work with Guzzle 7’s API changes.

Step 6: Commit

Once tests pass and we’ve manually verified critical paths, we commit:

$ git add composer.json composer.lock
$ git commit -m "Upgrade Guzzle HTTP client to 7.8.1"

This commit captures a complete, validated upgrade. Now anyone cloning this repository—or any environment running composer install—gets Guzzle 7.8.1, exactly what we tested.

Tip: Never run composer update on production. That command resolves versions based on what’s currently available—which could be different from what you tested. Always deploy the locked versions via composer install.

One may wonder: what if we want to see what would upgrade without actually changing anything? Use composer update --dry-run to preview changes safely.

Common Pitfalls and Edge Cases

In practice, these two mistakes account for the vast majority of lock file-related issues. Let’s examine them with the nuance they deserve.

Pitfall 1: Ignoring the Lock File

The mistake: Adding composer.lock to .gitignore.

This is a common mistake—especially among developers coming from npm, where lock file conventions differ. But in the PHP application development world, the lock file is essential to your repository. When we ignore it, we throw away the reproducibility that Composer’s two-file system provides.

Of course, there’s an important distinction: if you’re building a library intended for consumption by others, you might reasonably ignore the lock file. Libraries should remain flexible about their dependencies. But for applications—the software you actually deploy—the lock file belongs in version control.

Pitfall 2: Updating on Production

The mistake: Running composer update on production servers.

We cannot emphasize this enough: production should only ever run composer install. The upgrade command (composer update) queries remote repositories and resolves the latest versions allowed by your constraints. Those latest versions might be newer than what you tested in development—perhaps a package just released a bug fix that introduces a regression.

Remember the workflow: we update locally (or in a staging environment that mirrors production), run tests, verify everything works, then commit the updated lock file. Production merely installs what’s already been validated.

Tip: Some teams use a “no-update” flag on production as an extra safeguard: composer install --no-dev --no-interaction --prefer-dist --no-progress --optimize-autoloader. The explicit --no-update flag (available in Composer 2.5+) prevents any accidental resolution.

Other Considerations

What if the lock file gets out of sync? If composer.json and composer.lock diverge—say, someone manually edits composer.json but forgets to run composer update—you’ll see a warning:

composer.json and composer.lock are out of sync.

The solution is to run composer update to regenerate the lock file, then review the changes before committing.

Can I ever delete the lock file and start fresh? Yes, though it’s rarely necessary. You can remove composer.lock and run composer install to generate a new one. This might make sense if the lock file has become corrupted or if you’re intentionally resetting your dependency tree. Just remember: after regenerating, you should test thoroughly—the resolved versions may differ slightly from what was there before.

What about composer install --no-dev? That flag excludes development dependencies (like PHPUnit, PHPStan, or coding style tools). It’s appropriate for production deployments but should never be used in development or CI where you need those tools.

Conclusion

A disciplined lock file strategy isn’t a nice-to-have—it’s the foundation of professional PHP deployment. When we get this right, we eliminate an entire class of environment-specific bugs and gain confidence that our tested code will behave identically in production.

Let’s review the essential principles:

  • composer.json declares what dependencies we need, using flexible version constraints to allow upgrades
  • composer.lock captures exactly which versions were installed when we last updated
  • Version control requires both files—composer.lock must be committed alongside composer.json
  • composer install in development, CI, and production installs the locked versions—creating deterministic builds
  • composer update happens only intentionally—locally or in staging—followed by testing and committing the updated lock file

One may wonder: does this really matter? The answer is yes—especially as your team grows, your infrastructure scales, or your deployment frequency increases. The lock file strategy scales from solo developers to large engineering organizations because it creates a single source of truth for your dependencies.

By following these patterns, you’ll spend less time debugging environment differences and more time building features. That’s the peace of mind every PHP developer deserves.

Sponsored by Durable Programming

Need help with your PHP application? Durable Programming specializes in maintaining, upgrading, and securing PHP applications.

Hire Durable Programming