Tip of the day: patching legacy Drupal 7 projects with Composer

Submitted by Frederic Marand on

Some late Drupal 7 projects use Composer for project structure and tooling, even though they don't use Composer for the Drupal requirements proper. In that case, the normal Drupal 8/9 patching process using cweagans/composer-patches is not available, since dependencies are not handled with Composer. So is there a way to apply patches cleanly from Composer ? Sure there is! Let's see how.

The problem

A typical situation happens like this:

  1. Legacy Drupal 7 project keeps on being used, but not upgraded to Drupal 8 / Drupal 9 or BackdropCMS/li>
  2. Since it has to be maintainable, it is best on the then-current technique of git submodules, with each contrib being brought in as a separate submodule
  3. But then, more recent updates bring in Composer, e.g. to start using modern PHP components, starting the autoloader from settings.local.php; or using it for additional tools like Drush or PHPunit.
  4. When a module needs to be patched, the legacy drush-based process becomes an issue with submodules and Composer, and yet, because components are not downloaded by Composer, using cweagans/composer-patches is not an options

Now, how can the patch process be handled in composer.json, just as is would be in any modern Composer deployment ?

How to use Composer patches with zero plugin

There is actually a very simple mechanism for this: using Composer hooks on command events.

That's all we need. Let's take an example: how to add the PHP 7.4 compatibility patch for Gmap, available on the https://www.drupal.org/project/gmap/issues/3118279 issue ?

First step: creating a composer patch command

First we need to defined our patches and how to apply them. Here is what needs to be added to composer.json for a start:

{
  ...misc sections...
  "require": { ... },
  "require-dev: { ... },
  "scripts": {
    "patch": [
      "cd www/sites/all/modules/contrib/gmap; curl https://www.drupal.org/files/issues/2020-03-06/3118279-3.patch | patch -p1"
    ],
  },
  "type": "project"
}
  • The scripts section allows adding custom commands to Composer
  • In this case, we add a new patch command, to apply all patches
  • Since commands can be just shell scripts, we create one line for each patch, made of two parts:
    • cd (the module directory)
    • apply the patch straight from its download URL, taking advantage of the fact that the patch command takes patches form its standard input
  • These two have to be kept as a single shell command, because the working directory is only retained for the direction of the shell subprocess, hence the ;
  • With just that
At this step, we can now use composer patch to apply our patches on demand. But we can do better: apply the command automatically.

Second step: applying patches on composer install/update

Remember, Composer includes these command hooks for install and update. To quote the Composer documentation:

  • post-install-cmd: occurs after the install command has been executed with a lock file present.
  • post-update-cmd: occurs after the update command has been executed, or after the install command has been executed without a lock file present.

Beyond invoking shell scripts, Composer can also invoke its custom commands by prepending them with an @ like Symfony services

  "scripts": {
    "patch": [
      "cd www/sites/all/modules/contrib/gmap; curl https://www.drupal.org/files/issues/2020-03-06/3118279-3.patch | patch -p1"
    ],
    "post-install-cmd": ["@patch"],
    "post-update-cmd": ["@patch"]
  },

Now, any time we run composer install or composer update, Composer will apply the patches without having to type composer patch manually.

However, there are still two issues, which is especially annoying if the site uses submodules, but would also apply if all code is committed to the project:

  • After one of composer install or composer update, the files are patches. So any subsequent install or update will find them modified, and attempt to preserve them or rollback, requiring many interactions
  • The modified files are visible to git, which could invite us to commit them, which is probably not desired.

Third step: cleaning up

So we can just add an automatic cleanup before the install/update commands, so that our patch command, which runs after these tasks, will find the git checkout pristine

For projects committing contrib dependencies, a simple git reset --hard is all it takes, but that will not work with submodules, so we need to be a bit smarter:

  "scripts": {
    "patch": [
      "cd www/sites/all/modules/contrib/gmap; curl https://www.drupal.org/files/issues/2020-03-06/3118279-3.patch | patch -p1"
    ],
    "subclean": ["git submodule update --init -f"],
    "pre-install-cmd": ["@subclean"],
    "pre-update-cmd": ["@subclean"],
    "post-install-cmd": ["@patch"],
    "post-update-cmd": ["@patch"]
  },

This time we define a composer subclean command to clean up submodules, and we register it to run before both install and update.

Now, we can run composer install and composer update multiple times, and find the project clean everytime.

Finally, come commit time, we can just run composer subclean before committing, or even as a git pre-commit hook, and the patch-induced changes in our contributions will be reset, at no risk of getting committed.