Resolving merge conflicts is a great advanced part of Composer’s documentation, but we’ve found that it leaves many readers more confused than confident. If there is a composer.lock conflict, all you get is this error message:
$ git pull origin main
Auto-merging composer.lock
CONFLICT (content): Merge conflict in composer.lock
Recorded preimage for 'composer.lock'
Automatic merge failed; fix conflicts and then commit the result.
Thanks, git! But how do you fix these conflicts?
First, you must know what Composer commands were run on your own branch. In most cases, it’s running composer require
or composer update
to get a Drupal module. For this example, we’re going to assume it’s drupal/environment_indicator
. We’ll also assume you’re using ddev
for your local environment. If not, drop ddev
from the copy-paste block below.
# Stop the merge and roll back any changes - we'll retry later.
git reset --hard
# Update your main to match the remote main.
git checkout main && git pull
# Install dependencies so when you run commands later it's clear what is changing.
ddev composer install
# Checkout whatever branch you were on first.
git checkout -
# Restart the merge.
git merge main
# Get the composer.lock file from main and check it out.
git checkout --theirs main -- composer.lock
# Re-run your composer commands.
ddev composer require drupal/environment_indicator
git add composer.json composer.lock
git commit
Now, push your code and hope you can get it in before you need to repeat the process (or “reroll”) again!
OK, But Why All This Work?
A composer.lock
file contains a content-hash
property, which is where every conflict originates. Why couldn’t we just remove the property entirely and be done with conflicts forever?
The Composer docs provide an in-depth explanation of why we need to prevent text-based merges. Perhaps we can also find an original issue describing why a hash (and not some other mechanism) was chosen to prevent text-based merges of lock files.
The first code adding a lockfile hash is over at Locking mechanism implemented #40 in the Composer repository. There are no notes describing why adding a hash was needed. The closest is Implement lock mechanism #28 which unfortunately doesn’t answer this.
But the truth is, if you’ve used JavaScript package managers like npm
or yarn
, you’re probably asking yourself, “Why can’t we just do in Composer what they do?”.
It boils down to a key difference in the PHP and JavaScript languages. JavaScript can use closures to scope dependencies to just their consuming parent. In other words, this means that in a single project, Library A can depend on Library B 1.0
, and Library C
can depend on Library B 2.0
, and JavaScript is totally fine with that.
PHP’s class loader simply doesn’t work that way. When a PHP class is loaded, that is the one version of that class for the lifecycle of the PHP request. You couldn’t have version 1 and version 2 of a PHP library (or Drupal module) at the same time unless they renamed the library or class paths.
Given that constraint, Composer cannot implement nested dependencies like you can in the JavaScript ecosystem. That’s probably a good thing—nested dependencies are a major pain point in the JavaScript ecosystem and the cause of many security vulnerabilities in the software supply chain.
Knowing that Composer has to implement a flat dependency tree (where there is only ever one version of a dependency at a time), we can see how the lock file can represent this - as a simple array of package objects in the packages
key:
{
"_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#installing-dependencies",
"This file is @generated automatically"
],
"content-hash": "4c03575acd7a47b4a22ffd1947c31b91",
"packages": [
{
"name": "composer/ca-bundle",
"version": "1.5.0",
...
The content-hash
property serves as a flag describing all of the state of the Composer packages that could affect the rest of the file. We can see what is currently used to create this hash in the Locker class. Of note are the relevant keys - keys in composer.json
not in this list won’t change the lock hash!
$relevantKeys = [
'name',
'version',
'require',
'require-dev',
'conflict',
'replace',
'provide',
'minimum-stability',
'prefer-stable',
'repositories',
'extra',
];
One way to summarize the effect of the content-hash
property is that it makes the composer.lock
file act like an opaque binary file. While we can read the JSON, when it comes to git merges, we should pretend it’s not human-readable. Instead, we should recreate the steps that will give us the merged file we want, as if we were merging two different JPEG files.
Strategies to Reduce Conflicts
We use a few techniques to avoid Composer lock file conflicts in the first place. You’ll never avoid them (especially early in a project when you’re adding modules every day), but you can reduce the number of times you need to update a merge request.
Create a Pull Request For Composer Changes Only
In many tickets, there are several steps - require a module, install it, configure it, and write end-to-end tests for the new functionality. These don’t all need to be in a single pull request! For Composer dependencies, it can save a lot of time if you file a PR that just runs composer require …
with no other changes. After all, the module isn’t enabled or in use yet, so there isn’t much code review to do. Push it up and get it in!
Use Renovate for Upgrades
We use Renovate on all of our projects to manage dependency upgrades. Renovate can rebase and reroll Composer package upgrades automatically whenever main
is changed. Developers only need to run Composer commands to add or remove modules or for major Drupal upgrades. And because we can quickly get dependency upgrades in, we aren’t constantly rerolling the lock file.
Add composer validate to CI Checks and Build Scripts
It’s the worst when you go to update a Composer dependency and discover that what’s on main
is already broken. You can avoid this by running composer validate
in pull request checks. A good practice is to add it before composer install
in your automated test jobs. It’s very quick to run and will catch if you’ve forgotten to commit changes in either composer.json
or composer.lock
.
A Future Without Conflicts?
We’ve considered treating Composer changes more similarly to how other systems treat database migrations. In this model, developers would commit the steps to transform a Composer lock file (with require, update, and remove commands), allowing systems to automatically reroll the lock file. We do this in pull request descriptions for work like upgrading major Drupal versions, so why not automate it? But in practice, we’ve found that with the strategies above, committing these steps just hasn’t been worth the effort to implement.
There’s both RFC: Automatically resolve conflicts in composer.lock #6929 and [feature] Automatically recover from trivial merge conflicts in composer.lock#11517 with years of discussion between the two of them. However, as of today, there’s no better way to resolve conflicts than by following the steps in this article.