Leonardo Losoviz Freelance developer and writer, with an ongoing quest to integrate innovative paradigms into existing PHP frameworks, and unify all of them into a single mental model.

Tips for transpiling code from PHP 8.0 down to 7.1

9 min read 2579

Tips for Transpiling Code from PHP 8.0 Down to 7.1

Ideally, we should always install the latest version of PHP in our web servers. Right now, that’s PHP 8.0.

In many circumstances, however, this is not possible. Consider situations in which our clients are running legacy software that is incompatible with the latest PHP version. Or maybe we don’t control the environment, such as when building a plugin for WordPress for the general public.

In these situations, transpiling PHP code makes sense because it enables us to use the latest PHP features for development yet release the software with its code converted to an older PHP version for production.

A Meme Making Light of Coding with PHP 8.0 and Deploying as PHP 7.1

In this article, we’ll learn several tips for transpiling from PHP 8.0 to 7.1.

Is PHP 7.1 good enough?

Downgrading is accomplished via Rector, the PHP reconstructor tool. PHP 7.1 is the target to downgrade to because that’s currently the lowest PHP version Rector can handle for downgrades. (In the future, we will possibly be able to downgrade to 7.0 and 5.6.)

Since PHP 7.1 is already EOL, this should be enough for most scenarios. After all, we should always run an actively maintained PHP version only, which means PHP 7.3 and above. Otherwise, we risk using PHP containing unpatched vulnerabilities.

Unfortunately, this is not always the case. WordPress, for instance, still supports PHP 5.6, and therefore, a plugin using PHP 7.1 will not be available to users running WordPress on PHP 5.6 and 7.0, which currently sits at around 16.4 percent of all WordPress users.

If your users rely on legacy software and you’re currently developing with a very old version of PHP, such as 5.6, then you should consider whether jumping to PHP 7.1 is worth it. If it is, then you can directly jump to using PHP 8.0 thanks to transpiling.

In my situation, since only modern applications will be running GraphQL, my plugin GraphQL API for WordPress should not be greatly affected by leaving out users running on WordPress 5.6 and 7.0, so it’s worth it.

We made a custom demo for .
No really. Click here to check it out.

In the case of Yoast, though, the impact will be big: because it has over 5 million active installations, excluding 16.4 percent might mean around 1 million users. That’s not worth it.

What can we achieve by transpiling PHP code?

After introducing transpiling to my plugin, I’ve been able to bump its minimum required PHP version up to 8.0 (for development).

The payoff is a big one: by having access to PHP 8.0’s union types, plus PHP 7.4’s typed properties, I’ve been able to completely add strict types everywhere in the plugin’s codebase (including all function parameters, return statements, and class properties), which translates to fewer bugs and more understandable code.

I’m thrilled at this piece of code that I can now produce:

interface CustomPostTypeAPIInterface
{
  public function createCustomPost(array $data): string | int | null | Error;
}

The return type of this function expresses that one of these situations took place:

  • The new custom post object was successfully created by returning its ID, which is of either type string or int
  • The new object was not created due to failing validation by returning null
  • The new object was not created due to something going wrong in the process (for instance, connecting to a required third-party API failed) by returning a custom object of type Error, which also contains an error message

Thus, transpiling gives me the chance to become a better developer, producing code with higher quality.

How transpiled code behaves in production

After transpiling the code above to PHP 7.1, the return type will be removed:

interface CustomPostTypeAPIInterface
{
  public function createCustomPost(array $data);
}

Now, if there was a type mismatch between the return type of this function and where it’s being invoked, I will already be aware of it during development and fix the issue.

Hence, removing the return type for production does not produce any consequence.

Which new features become available?

Being able to code with PHP 8.0 does not mean that every single feature from PHP versions 8.0, 7.4, 7.3, and 7.2 can be used. Rather, only those features for which there is a downgrade rule in Rector can be used, plus those ones being backported by Symfony’s polyfill packages (polyfill-php80, polyfill-php74, polyfill-php73, and polyfill-php72).

For instance, there is currently no way to downgrade PHP 8.0’s attributes, so we can’t use this feature. As of this writing, the list of available PHP features for an application coded with PHP 8.0 to be downgraded to 7.1 is the following:

PHP version Features
7.1 Everything
7.2 ✅  object type
✅  Parameter type widening
✅  PREG_UNMATCHED_AS_NULL flag in preg_match
✅  Functions:

✅  Constants:

7.3 ✅  Reference assignments in list() / array destructuring => [&$a, [$b, &$c]] = $d except inside foreach (#4376)
✅  Flexible Heredoc and Nowdoc syntax
✅  Trailing commands in function calls
✅  set(raw)cookie accepts $option argument
✅  Functions:

Exceptions:

7.4 ✅  Typed properties
✅  Arrow functions
✅  Null coalescing assignment operator => ??=
✅  Unpacking inside arrays => $nums = [3, 4]; $merged = [1, 2, ...$nums, 5];
✅  Numeric literal separator => 1_000_000
✅  strip_tags() with array of tag names => strip_tags($str, ['a', 'p'])
✅  Covariant return types and contravariant param types
✅  Functions:

8.0 ✅  Union types
✅  mixed pseudo type
✅  static return type
✅  ::class magic constant on objects
✅  match expressions
✅  catch exceptions only by type
✅  Null-safe operator
✅  Class constructor property promotion
✅  Trailing commas in parameter lists and closure use lists
✅  Interfaces:

  • Stringable

✅  Classes:

  • ValueError
  • UnhandledMatchError

✅  Constants:

  • FILTER_VALIDATE_BOOL

✅  Functions:

Performing the transpiling

The Rector configuration to convert code from PHP 8.0 all the way down to PHP 7.1 is this one:

return static function (ContainerConfigurator $containerConfigurator): void {
  // get parameters
  $parameters = $containerConfigurator->parameters();

  // here we can define, what sets of rules will be applied
  $parameters->set(Option::SETS, [
    DowngradeSetList::PHP_80,
    DowngradeSetList::PHP_74,
    DowngradeSetList::PHP_73,
    DowngradeSetList::PHP_72,
  ]);
}

Transpiling code for production only

We need to transpile all the code that makes up our project, which includes our source code and all the third-party packages it depends on.

Concerning packages, we do not need to transpile all of them; only those that will be part of the deliverable. In other words, only packages for PROD, not DEV.

This is good news, because:

  • Running Rector on the codebase will take some time, so removing all unneeded packages (such as PHPUnit, PHPStan, Rector itself, and others) will decrease the running time
  • The process most likely will not be completely smooth (some files may produce errors and need some custom solution). Thus, the fewer the files to transpile, the less effort is required

We can find out which are the PROD dependencies in Composer like this:

composer info --name-only --no-dev

The following Bash script computes the list of all paths to downgrade (i.e. the source code for the project and its PROD dependencies) and applies Rector on them:

# Get the paths for all PROD dependencies
# 1. `composer`: Get the list of paths, in format "packageName packagePath"
# 2. `cut`: Remove the packageNames
# 3. `sed`: Remove all empty spaces
# 4. `tr`: Replace newlines with spaces
paths="$(composer info --path --no-dev | cut -d' ' -f2- | sed 's/ //g' | tr '\n' ' ')"

# Execute the downgrade
# 1. Project's source folder as "src"
# 2. All the dependency paths
vendor/bin/rector process src $paths --ansi

The config must exclude running Rector on all test cases. Otherwise, Rector will throw an error because PHPUnit\Framework\TestCase is missing in PROD. Different dependencies may place them on different locations, which is how we need to fine-tune our Rector config. To find out, we can inspect their source code or run Rector and see if/how it fails.

For my plugin, the folders to skip (including those from the plugin’s source code and its dependencies) are these:

$parameters->set(Option::SKIP, [
  // Skip tests
  '*/tests/*',
  '*/test/*',
  '*/Test/*',
]);

Watch out for dependency inconsistencies

Sometimes, dependencies may reference some external class that is loaded for DEV. When Rector analyzes the dependency, it will throw an error because the referenced code does not exist for PROD.

For instance, class EarlyExpirationHandler from Symfony’s Cache component implements interface MessageHandlerInterface from the Messenger component:

class EarlyExpirationHandler implements MessageHandlerInterface
{
    //...
}

However, symfony/cache‘s dependency on symfony/messenger is on require-dev, not on require. So, if our project has a dependency on symfony/cache and we analyze it with Rector, it will throw an error:

[ERROR] Could not process "vendor/symfony/cache/Messenger/EarlyExpirationHandler.php" file, due to:
  "Analyze error: "Class Symfony\Component\Messenger\Handler\MessageHandlerInterface not found.". Include your files in "$parameters->set(Option::AUTOLOAD_PATHS, [...]);" in "rector.php" config.
  See https://github.com/rectorphp/rector#configuration".

To solve this, first check whether this is a bug in the dependency’s repo. In this case, should symfony/messenger be added to the require section of symfony/cache? If you don’t know the answer, you can ask via an issue on their repo.

If it is a bug, it will hopefully be fixed, and you can wait for that change to happen (or even contribute it directly). Otherwise, you need to consider whether your project for production uses the class producing the error or not.

If it does use it, then you can load the missing dependency on Rector’s config through its Option::AUTOLOAD_PATHS config:

$parameters->set(Option::AUTOLOAD_PATHS, [
  __DIR__ . '/vendor/symfony/messenger',
]);

If it doesn’t use it, then you can directly skip the file altogether so Rector won’t process it:

$parameters->set(Option::SKIP, [
  __DIR__ . '/vendor/symfony/cache/Messenger/EarlyExpirationHandler.php',
]);

Optimizing the transpiling process

The Bash script we saw earlier on was simple because it is downgrading all PROD dependencies from PHP 8.0 to 7.1.

Now, what happens if any dependency is already on PHP 7.1 or below? Running Rector on its code will not produce side effects, but it’s a waste of time. If the there’s a lot of code, then the wasted time will become significant, making us wait longer for the CI process to complete when testing/merging a PR.

Whenever that happens, we’d rather run Rector only on those packages containing code that must be downgraded, not on all of them. We can find out which packages these are via Composer. Since dependencies normally specify what version of PHP they require, we can deduce which are the packages that require PHP 7.2 and above like this:

composer why-not php "7.1.*" | grep -o "\S*\/\S*"

For some reason, composer why-not does not work with the --no-dev flag, so we need to install only PROD dependencies for obtaining this information:

# Switch to production, to calculate the packages
composer install --no-dev --no-progress --ansi
# Obtain the list of packages needing PHP 7.2 and above
packages=$(composer why-not php "7.1.*" | grep -o "\S*\/\S*")
# Switch to dev again
composer install --no-progress --ansi

With the list of package names, we calculate their paths like this:

for package in $packages
do
  # Obtain the package's path from Composer
  # Format is "package path", so extract everything after the 1st word with cut to obtain the path
  path=$(composer info $package --path | cut -d' ' -f2-)
  paths="$paths $path"
done

Finally, we run Rector on all the paths (and the project’s source folder):

vendor/bin/rector process src $paths --ansi

Watch out for chained rules

In some situations, we may encounter chained rules: the code produced from applying a downgrade rule will itself need be modified by another downgrade rule.

We might expect that defining the rules in their expected execution order will deal with chained rules. Unfortunately, this doesn’t always work because we do not control how PHP-Parser traverses the nodes.

This situation happened on my project: symfony/cache has file vendor/symfony/cache/CacheItem.php with function tag returning ItemInterface:

final class CacheItem implements ItemInterface
{
    public function tag($tags): ItemInterface
    {
        // ...
        return $this;
    }
}

The implemented interface ItemInterface, instead, returns self on function tag:

interface ItemInterface extends CacheItemInterface
{
    public function tag($tags): self;
}

The downgrade set for PHP 7.4 contains the following two rules, defined in this order:

$services = $containerConfigurator->services();
$services->set(DowngradeCovariantReturnTypeRector::class);
$services->set(DowngradeSelfTypeDeclarationRector::class);

When downgrading class CacheItem, function tag should be modified twice:

  1. DowngradeCovariantReturnTypeRector must first transform the return type from ItemInterface to self
  2. DowngradeSelfTypeDeclarationRector should then remove the self return type

But the second step is not happening. As a consequence, after running the downgrade, function tag returns self, which will not work for PHP 7.3 and below.

The solution I came up with to tackle this problem involves two steps:

  1. Finding out whenever such problems occur (it will be exceptional)
  2. “Manually” fixing the problem by running a second Rector process, with its own config, specifically to address the issue

Let’s see how they work.

1. Finding out whenever such problems occur

Normally, we expect to run Rector once and have it execute all the required modifications. Then, if we run Rector a second time (on the output from the first execution), we expect no code to be modified. If any code is modified on the second pass, that means something did not go well on the first pass. Most likely, it was a chained rule that was not applied.

Rector accepts flag --dry-run, which means it will print the modifications on screen but without actually applying them on the code. Conveniently, running Rector with this flag will return an error whenever there is a modification.

Then, we can run rector process --dry-run as the second pass in our CI. Whenever the CI process fails, the output in the console will show which rule was applied on this second pass, thus pointing out which is the chained rule that was not applied on the first pass.

Running the second pass has an additional benefit: if the produced PHP code is buggy (which may occasionally happen, as with this example), then Rector’s second pass will fail. In other words, we are using Rector to test the output from Rector itself.

2. ‘Manually’ fixing the problem

Once we discover that a rule was not executed on some node, we must introduce a way to apply it immediately after the first Rector pass. We could run the same Rector process again, but that’s inefficient because this process involves dozens of rules applied on thousands of files, taking several minutes to complete.

But the issue will most likely involve a single rule and a single class. So we’d rather create a second Rector config, which will require only a few seconds to execute:

return static function (ContainerConfigurator $containerConfigurator): void {
  $parameters = $containerConfigurator->parameters();
  $parameters->set(Option::PATHS, [
    __DIR__ . '/vendor/symfony/cache/CacheItem.php',
  ]);

  $services = $containerConfigurator->services();
  $services->set(DowngradeSelfTypeDeclarationRector::class);
};

To support having to process more than one additional Rector config, we can pass a list of Rector configs to a Bash script:

# Execute additional rector configs
# They must be self contained, already including all the src/ folders to downgrade
if [ -n "$additional_rector_configs" ]; then
    for rector_config in $additional_rector_configs
    do
        vendor/bin/rector process --config=$rector_config --ansi
    done
fi

Conclusion

Transpiling PHP code is an art in itself, requiring a bit of effort to set up. More likely than not, we will need to fine-tune the Rector configuration for it to work perfectly with our project, given which dependencies it needs and which PHP features these make use of.

However, transpiling code is an incredibly powerful experience that I heartily recommend. In my own case, I’m able to use PHP 8.0 features for my publicly available WordPress plugin (something that is quite unheard of otherwise), allowing me to add strict typing on its codebase, thus lowering the likelihood of bugs and improving its documentation.

: Full visibility into your web apps

LogRocket is a frontend application monitoring solution that lets you replay problems as if they happened in your own browser. Instead of guessing why errors happen, or asking users for screenshots and log dumps, LogRocket lets you replay the session to quickly understand what went wrong. It works perfectly with any app, regardless of framework, and has plugins to log additional context from Redux, Vuex, and @ngrx/store.

In addition to logging Redux actions and state, LogRocket records console logs, JavaScript errors, stacktraces, network requests/responses with headers + bodies, browser metadata, and custom logs. It also instruments the DOM to record the HTML and CSS on the page, recreating pixel-perfect videos of even the most complex single-page apps.

.
Leonardo Losoviz Freelance developer and writer, with an ongoing quest to integrate innovative paradigms into existing PHP frameworks, and unify all of them into a single mental model.

One Reply to “Tips for transpiling code from PHP 8.0 down to…”

  1. PHP 8 contains so many features like named parameters and again it is much faster than the previous version. We are still on PHP 7.4 but we would like to upgrade soon. I hope the people from Ubuntu integrate the new versions much faster….

Leave a Reply