Upgrading Drupal to PHP 8

October 19, 2020 | 10 Minute Read

Tags:Engineering Drupal,PHP 8,Drupal

PHP 8 beta 4 is out. In fact, the chances are that by the time you read this, we might even have the first RC.

PHP 8 adds a lot of exciting new features, but at the same time, being a major version, it breaks a lot of previous behaviors and functionalities.

Getting Drupal to work on PHP 8 is not as simple as getting it to work on a new minor release such as PHP 7.4.

The Drupal community began planning to fix the compatibility issues early on. And as releases started rolling out, there were individual issues to address each deprecation, changed method signatures, and other breaking changes. These fixes went into a single issue so that we could run a single test against PHP 8. That is the patch I started with when I wanted to test Drupal 9 with PHP 8. To make it even more fun, I also used Composer 2 for all of these steps.

Why am I writing this?

It’s clear that this article may not have any value at all in some time when Drupal 9 officially supports PHP 8, along with all of its dependencies. Why am I writing this then? For one, I believe writing down things helps clarify the ideas and goals. It serves as documentation that can help throughout the process of experimentation. Secondly, I hope that parts of this article will be useful to people who are trying to upgrade their own complex applications to work with PHP 8.

The challenges I describe here are more relevant to applications that need to support a spectrum of PHP versions, not just one or two.

Drupal 8 supports PHP 7.0 to 7.4 right now and the issue I mentioned earlier also tries to add support for PHP 8 to Drupal 8.9 as well (it looks like it might just happen as well). This makes the challenge of supporting PHP 8 in Drupal even bigger as we have to support several breaking changes simultaneously.

Also, many of the problems may not be relevant to applications that need to run on one version of PHP as they just have to change code to match the changes in PHP 8.

I will also not try to explain all the changes that have gone in to support PHP 8. I’ll just talk about the parts that I analysed, reviewed, or changed myself. With all that said, let’s begin.

Problem 1: Environment and initial setup

Docker is great for setting up quick environments for testing and development. At Axelerant, we usually use Lando for setting up a project (in fact, our project template tool supports generating a default scaffold for Lando). But that wouldn’t fit my needs here because Lando doesn’t support PHP 8 yet. Anyway, docker-compose is much simpler for something like this. I only need two services to begin with–a web server container (with PHP) and a database.

The Docker and PHP community maintain a great starting point in the form of official PHP Docker images in a variety of flavors: CLI, FPM, and with Apache on Buster. We use the last one here and add various PHP extensions and settings optimized for Drupal. I already maintain a collection of Drupal optimized PHP images and I only adapted that to work with the PHP 8 beta 4 image. The only difference is that as pecl is no longer included with PHP (as of PHP 8), I just removed those lines. That meant that the common PHP extensions such as APCu and YAML wouldn’t be available, but that’s okay for the first attempt. (I eventually added them in the image anyway.)

The other service in the docker-compose file is for MariaDB and I use Bitnami’s Docker version here. There’s an official one, but I am more used to the Bitnami one as Lando uses it. I don’t do any fancy setup with MariaDB as this is just for experimentation. You can see the docker-compose.yml and Dockerfile on Github. Apart from the Docker environment, we also need a Drupal site setup. Fortunately, this was very easy with the templating tool I mentioned earlier. Once the axl-template tool is installed, I just run this command:

init-drupal hussainweb/test-d9p8c2 --core-version "^9.1@dev"

Typically, this would have been enough for a good starting point, but the template is optimized for composer 1. It includes certain packages that improve the composer’s performance with Drupal. However, I want to use composer 2 and those packages are not required. In fact, they don’t even work. So, after the init-drupal command above, I remove that package before upgrading the composer to version 2. I also update composer-patches to the latest dev release, which has the updated “require” statements to work with composer 2.

composer remove zaporylie/composer-drupal-optimizations
composer require cweagans/composer-patches:"^1.0@dev"

Now, I’m good to update composer to the latest version (2.0 RC1 as of this writing):

composer self-update --2

At the time of this writing, the composer-patches plugin doesn’t work with composer 2. I have a PR open for the final fix (as of now) and I just used my fork as the repository for the package. Normally, in the Drupal world, I would have tried to apply a patch, but the plugin responsible for applying patches is broken here. Anyway, using forks is better. This commit shows how I used my fork, which works properly with composer 2. By the time you’re reading this, you might not have to do this at all.

Another thing to note is that my local machine is still running PHP 7.4. This is important because many of Drupal’s dependencies do not support PHP 8 and cannot be installed (unless we use the --ignore-platform-requirements flag).

This is good enough to get a basic environment running. Spin up the docker containers and find the port that the container exposes (look at docker ps -a). Access the site, and you might see an error message.

Problem with dependencies

Drupal is built on top of many packages and components in the PHP world and they have to support PHP 8 as well. As of right now, most of these components are not easily installable on PHP 8 due to the requirements in their composer.json. However, I ran composer on my local machine, which still runs PHP 7.4 and accordingly, it didn’t complain about PHP 8. This was obviously a risk and we should never do this on a production site (you shouldn’t be using PHP 8 beta on production right now anyway). But for this experiment, I wanted to try running those components on PHP 8 despite their composer.json restrictions. The good news is that none of those components caused any problems in my tests so far.

Of course, this is a blocker to add PHP 8 support for Drupal and is being tracked in this issue.

Problem 2: Fix Drupal errors

First, I faced problems with incompatible method declarations in doctrine/reflection. The problems were in methods getConstants and newInstance and I manually fixed both those instances (it was trivial) and it moved ahead. It turned out that they had already been fixed in the patch and I need not have worried but I moved on.

This fix at least got Drupal’s installation pages loaded and I went as far as the page where we enter the database details. (I eventually configured the .env file and never saw that page again, but that’s beside the point.) At this step, I saw an error related to an invalid method signature for a PDO method. In PHP 8, the method signatures of PDOStatement::fetchAll and PDOStatement::fetch have changed. Unfortunately, Drupal 8 and 9 have a wrapper on this method with the old signature. This now needs to change for PHP 8. But changing it will break support for PHP 7 and this creates our complication.

The solution is a rather brilliant hackery by Alex Pott where we introduce two interfaces–one for PHP 7 and the other for PHP 8. Depending on the PHP version, we alias the relevant interface which gets used by the actual class. Then, to handle both method signatures, we have two different traits–again one for PHP 7 and the other for PHP 8. We again alias the relevant trait depending on the PHP version, which gets used in the class. This looks something like this:

if (PHP_VERSION_ID >= 80000) {
  class_alias('\Drupal\Core\Database\Php8StatementInterface',  '\Drupal\Core\Database\StatementInterfaceBase');
}
else {
  class_alias('\Drupal\Core\Database\Php7StatementInterface',   '\Drupal\Core\Database\StatementInterfaceBase');
}
interface StatementInterface extends StatementInterfaceBase, \Traversable {
  // ..
}

Similarly, the traits are aliased and each of the traits call a new helper method in the actual Statement class (they are just renamed from the previous method). For example, the erstwhile fetchAll method would now become doFetchAll and since it is a different name, it doesn’t matter what signature it has. The fetchAll method would now reside in the relevant trait with the appropriate signature depending on the PHP version and would just call the doFetchAll method. This way, we have two different method signatures in the interfaces and traits depending on the PHP version!

The above changes can be reviewed in the patch at the issue where this is being worked at the time of this writing. When I tested, the patch only contained support for the differing signatures for the fetch method. Subsequently, the signature for the fetchAll method had changed as well and I added support for that in the patch. This solved the problem with installing Drupal. I was surprised and happy that there were no more errors during the rest of the installation and even when I was greeted with my new site’s homepage running Drupal 9.1 and PHP 8!

Before I go on to the next problem, I should note that the issue here with PDOStatement is really because of a mistake in the design.

As a developer, we should never directly depend on the API of something we can’t directly control. PHP version is something that we, as Drupal core developers, can’t control. We can certainly require a minimum version of PHP but we can’t control PHP to control other dependencies.

Intertwining our business logic with PHP’s API brings risks such as this. Like Alex Pott says in a comment in that issue, “These are not our methods. These are from \PDOStatement and their signature is owned by PHP and not by Drupal.”

Fortunately, Drupal boasts a high code coverage in the automated tests and refactoring could be as safe as we can hope. There might still be problems with contributed modules that might have extended this method and rely on Drupal’s implementation on top of the PHP wrapper. In a complex product like Drupal, it is precarious to refactor code like this.

Problem 3: Dynamic routes

I was elated with a working install on PHP 8 and wanted to test further, all the while keeping an eye on the error log. I found a few niche errors in how Drupal behaved on dynamic routes but that turned out to be a problem with the changes in PDOStatement::fetchAll and how it behaves with optional parameters. It was very messy and I am glad that PHP 8 changed the method signature to use variadics. The problem here was how the traits wrapped the call to the relevant Statement class's actual method. I had to use an ugly switch..case block to account for the number of parameters similar to how it was already handled in Drupal core. You can see the changes in this patch.

Problem 4: CKEditor warnings

I noticed multiple warnings being logged by CKEditor when I opened the node add/edit form. The warnings from a method called CKEditorPluginManager::getEnabledButtons and it was due to how a certain parameter was passed into the callback for array_reduce. Fortunately, this turned out to be an easy fix because there was no need at all to pass that parameter by reference and the patch was quickly committed. The issue contains more details and sample code for reproducing the issue.

Next steps

If you want to test this yourself, find my code here and set it up yourself locally. You would need composer and Docker and hope it is self-explanatory, but I will soon add documentation to the repository.

I was able to run Drupal 9 and perform many actions on PHP 8. But not many people need just the Drupal core. To test complex sites, I needed to test as many features as possible in core and also contrib modules. I will write about this in a subsequent post and maybe even do a simple benchmark comparing PHP 7.4 and 8.0 performance.