It's no secret that I find Composer a very troublesome piece of software to work with.
I have issues with Composer on two fronts. First, its output is extremely user-unfriendly, such as the long lists of impenetrable statements about dependencies that it produces when it tells you why it can't make a change you request. Second, many Composer commands have unwanted side-effects, and these work against the practice that changes to your codebase should be as simple as possible for the sake of developer sanity, testing, and user acceptance.
I recently discovered that removing packages is one such task where Composer has ideas of its own. A command such as remove drupal/foo will take it on itself to also update some apparently unrelated packages, meaning that you either have to manage the deployment of these updates as part of your uninstallation of a module, or roll up your sleeves and hack into the mess Composer has made of your codebase.
Guess which option I went for.
Step 1: Remove the module you actually want to remove
Let's suppose we want to remove the Drupal module 'foo' from the codebase because we're no longer using it:
$ composer remove drupal/foo
This will have two side effects, one of which you might want, and one of which you definitely don't.
Side effect 1: dependent packages are removed
This is fine, in theory. You probably don't need the modules that are dependencies of foo. Except... Composer knows about dependencies declared in composer.json, which for Drupal modules might be different from the dependencies declared in module info.yml files (if maintainers haven't been careful to ensure they match). UPDATE: I've been informed in comments that drupal.org's packaging process ensures these are kept in sync. So that's one less thing to worry about!
Furthermore, Composer doesn't know about Drupal configuration dependencies. You could have the situation where you installed module Foo, which had a dependency on Bar, so you installed that too. But then you found Bar was quite useful in itself, and you've created content and configuration on your site that depends on Bar. Ideally, at that point, you should have declared Bar explicitly in your project's root composer.json, but most likely, you haven't.
So at this point, you should go through Composer's output of what it's removed, and check your site doesn't have any of the Drupal modules enabled.
I recommend taking the list of Drupal modules that Composer has just told you it's removed in addition to the requested one, and checking its status on your live site:
$ drush pml | ag MODULE
If you find that any modules are still enabled, then revert the changes you've just made with the remove command, and declare the modules in your root composer.json, copying the declaration from the composer.json file of the module you are removing. Then start step 1 again.
Side effect 2: unrelated packages are updated
This is undesirable basically because any package update is something that has to be evaluated and tested before it's deployed. Having that happen as part of a package removal turns what should be a straight-forward task into something complex and unpredictable. It's forcing the developer to handle two operations that should be separate as one.
(It turns out that the maintainers of Composer don't even consider this to be a problem, and as I have unfortunately come to expect, the issue on github is a fine example of bad maintainership (for the nadir, see the issue on the use of JSON as a format for the main composer file) -- dismissing the problems that users explain they have, claiming the problems are by design, and so on.)
So to revert this, you need to pick apart the changes Composer has made, and reverse some of them.
Before you go any further, commit everything that Composer changed with the remove command. In my preferred method of operation, that means all the files, including the modules folder and the vendor folder. I know that Composer recommends you don't do that, but frankly I think trusting Composer not to damage your codebase on a whim is folly: you need to be able to back out of any mess it may make.
Step 2: Repair composer.lock
The composer.lock file is the record of how the packages currently are, so to undo some of the changes Composer made, we undo some of the changes made to this file, then get Composer to update based on the lock.
First, restore version of composer.lock to how it was before you started:
$ git checkout HEAD^ composer.lock
Unstage it. I prefer a GUI for git staging and unstaging operations, but on the command line it's:
$ git reset composer.lock
Your composer lock file now looks as it did before you started.
Use either git add -p or your favourite git GUI to pick out the right bits. Understanding which bits are the 'right bits' takes a bit of mental gymnastics: overall, we want to keep the changes in the last commit that removed packages completely, but we want to discard the changes that upgrade packages.
But here we've got a reverted diff. So in terms of what we have here, we want to discard changes that re-add a package, and stage and commit the changes that downgrade packages.
When you're done staging you should have:
- the change to the content hash should be unstaged.
- chunks that are a whole package should be unstaged
- chunks that change version should be staged (be sure to get all the bits that relate to a package)
Then commit what is staged, and discard the rest.
Then do a git diff of composer.lock against your starting point: you should see only complete package removals.
Step 3: Restore packages with unrelated changes
Finally, do:
$ composer update --lock
This will restore the packages that Composer updated against your will in step 1 to their original state.
If you are committing Composer-managed packages to your repository, commit them now.
As a final sanity check, do a git diff against your starting point, like this:
$ git diff --name-status master
You should see mostly deleted files. To verify there's nothing that shouldn't be there in the changed files, do:
$ git diff --name-status master | ag '^[^D]'
You should see only composer.json, composer.lock, and the autoloader's files.
PS. If I am wrong and there IS a way to get Compose to remove a package without side-effects, please tell me.
I feel I have exhausted all the options of the remove command:
- --no-update only changes composer.json, and makes no changes to package files at all. I'm not sure what the point of this is.
- --no-update-with-dependencies only removes the one package, and doesn't remove any dependencies that are not required anywhere else. This leaves you having to pick through composer.json files yourself and remove dependencies individually, and completely obviates the purpose of a package manager!
Why is something as simple as a package removal turned into a complex operation by Composer? Honestly, I'm baffled. I've tried reasoning with the maintainers, and it's a brick wall.
PPS. Since writing this post, I’ve made Composer Manifest a small Composer plugin which makes it easier to see what Composer has decided to change behind your back. Every time you do a Composer update, install, or remove, it writes a YAML file that lists all the installed packages with their versions. Committing that to your repository means you have an easy way to see exactly what's been changed and when.