Skip to main content

Adding backward compatibility to Rector rules

Published on

Rector is a PHP tool that automates refactoring your code to take advantage of the latest language-level features in PHP or automated usages of deprecated code when upgrading your dependencies. The Drupal community has relied heavily on Rector via the drupal-rector extension to automate major version upgrade fixes. Also, Tomas Votruba, the creator of Rector, is consistently blogging and creating tooling to elevate the developer experience. Rector is a great tool, but there are a lot of consequences when using it for contributed code.

On April 26th, I'll present "Lessons learned from helping port the top contrib projects to Drupal 10" at MidCamp. Working on my slides has caused a lot of retrospection from this summer's effort for Drupal 10 readiness. This blog post comes from the perspective of being a primary contributor to drupal-rector, a module maintainer receiving patches created by drupal-rector, and working with a team leveraging drupal-rector. One lesson learned is that end-users need easy upgrade paths with as many backward compatibilities as possible to lower the effort to perform upgrades. This problem made me wonder: can we find a way to use Rector to automate code fixes while providing as much backward compatibility for users as possible?

The problem of Rector and backward compatibility

I have one problem with Rector. It is a sledgehammer yet surgical in its effects. I find Rector essential for end-user code where there are no other consumers of your codebase than yourself. Not so much for code intended to be distributed and used by end-users. Why? Because Rector does not care about backward compatibility in its refactoring. Rector is powered by rules written to identify code signatures and then replace them with their refactored counterparts. It surgically refactors code. But that is also why it is like a sledgehammer — brute force fixes without providing backward compatibility support.

For example, Rector can make a PHP 7.4 code base PHP 8 compliant, but it will also prevent your code base from running on PHP 7.4. This may be a familiar situation for folks moving off of the end-of-life version and onto PHP 8.1 or 8.2. Your server may still be running PHP 7.4, and your code must be compatible with PHP 8 before changing your server to PHP 8. The solution would manually configure the required Rector rules and not use the SetList::PHP_80 config setlist. 

What if you maintain a package and want to support some level of backward compatibility? You could release a new major version of your package that drops PHP 7.4 for PHP 8+ and let semantic versioning with Composer's dependency resolution handle the rest. But is that a great end-user experience? Your package may only need a minor release to make the codebase PHP 8 compatible while saving a later major release for taking full advantage of PHP 8 language-level features. This gives your end-users a bridge to migrate their code gradually without a big-bang effort. The graphic below shows that the backward-compatible minor version supporting PHP 7.4 and 8.0 provides a bridge for end-users to upgrade their code.

Let's look at another example with Drupal core and deprecations that occur within minor releases. Each minor release has security support for one year. Drupal 10.1.0 is slated to release on June 23, 2023, and will introduce new deprecations with replacements. Drupal 10.0.x will lose security support when Drupal 10.2.0 is released on December 13, 2023. Suppose a module implements deprecated code fixes for 10.1.0. That module will no longer be able to support Drupal 10.0.0, which is a supported version of Drupal core. The module maintainer could wait until 10.2.0 is released, drop support for 10.0.x and fix deprecations from 10.1.x. With this pattern, deprecated code fixes lag by one minor release. Using this approach, Rector is a perfect tool for providing fixes.

There is one problem. This assumes end-users update their software in a timely fashion. It also assumes module maintainers properly implement semantic versioning and cherry-picked security releases. The world isn't perfect, and I cannot think of one Drupal module that implements this. The solution is to build backward compatibility around the deprecated code. User code bases with the latest version of Drupal core avoid calling deprecated code, and users with an older version of Drupal core can still update their modules. 

At Acquia, we supported Drupal 8.9 to 10 for many of our modules. Yes, some Drupal users are still on Drupal 8.9. Our code's surface area against deprecations were relatively small, mainly around calling the event dispatcher. In Symfony 4.3 the arguments to the dispatch method for the event dispatcher were changed, which was introduced in Drupal's 9.1.x release cycle.. The following snippet enabled our code to work across three major versions of Drupal core:

// @todo Remove after dropping support for Drupal 8.
if (version_compare(\Drupal::VERSION, '9.0', '>=')) {
	$this->dispatcher->dispatch($event, Events::EVENT_NAME);
}
else {
	// @phpstan-ignore-next-line
	$this->dispatcher->dispatch(Events::EVENT_NAME, $event);
}

Kudos to Jakob Perry for pushing for these compatibility layers and providing a better experience for our end users. I was on the "drop Drupal 8.9 and make them upgrade Drupal core first." That path would prevent customers from receiving bug fixes or security releases due to other blockers preventing their Drupal core upgrades.

Building better Rector rules for backward compatibility.

The good news is that we can improve how drupal-rector rules work. So, what if we provided backward compatibility into the rules of drupal-rector?

Take the example of the changes in Symfony for the event dispatcher. In drupal-rector, we include the upgrade rules from the Symfony extension to ensure Drupal 9 code bases are all ready for deprecations in Symfony 4.

$rectorConfig->sets([
    PHPUnitSetList::PHPUNIT_80,
    SymfonySetList::SYMFONY_40,
    SymfonySetList::SYMFONY_41,
    SymfonySetList::SYMFONY_42,
    SymfonySetList::SYMFONY_43,
    SymfonySetList::SYMFONY_44
]);

The SymfonySetList::SYMFONY_43 registers the MakeDispatchFirstArgumentEventRector rule to refactor calls to dispatch to make the event object the first argument. This fix was provided by the Rector community and only required our inclusion. But what if we chose to be more explicit on the included rules and made our version of MakeDispatchFirstArgumentEventRector that provided backward compatibility? 

I envision how this would work, and I still need to draft the code. But here is my concept so far:

  • Rules providing backward compatibility must implement an interface.
  • This interface requires the module to declare a Drupal version number for version_compare.
  • Create a new PostRectorInterface that checks for the new interface and ensures the backward compatibility condition statements are inserted
  • The new code will be placed inside the if statement.
  • The existing code will be placed in the else statement with a @phpstan-ignore-next-line comment to silence deprecation errors from PHPStan
  • A Rector configuration setting would be used to turn this on, having it off by default.
  • The Project Update Bot's Rector configuration would have this setting on so that automated fixes delivered to modules on Drupal.org always have backward-compatible patches delivered.

The problem is that this may conflict with NodeAddingPostRector, which is used when a single line is refactored into multiple lines. This PostRector, if the right approach, would need to run before that one.

With Drupal 11 coming in 2024, this feature is extremely important.

As a note, I work on phpstan-drupal and drupal-rector in my free time, so I cannot promise a timeline to work on this. I set aside two hours every Wednesday afternoon to do my open source work via live stream on my Twitch channel; I plan to start work on this in the next few weeks.

I'm available for one-on-one consulting calls – click here to book a meeting with me 🗓️