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.
In this article, we’ll learn several tips for transpiling from PHP 8.0 to 7.1.
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.
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.
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:
string
or int
null
Error
, which also contains an error messageThus, transpiling gives me the chance to become a better developer, producing code with higher quality.
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.
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:
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, ]); }
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:
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/*', ]);
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', ]);
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
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:
DowngradeCovariantReturnTypeRector
must first transform the return type from ItemInterface
to self
DowngradeSelfTypeDeclarationRector
should then remove the self
return typeBut 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:
Let’s see how they work.
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.
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
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.
Install LogRocket via npm or script tag. LogRocket.init()
must be called client-side, not
server-side
$ npm i --save logrocket // Code: import LogRocket from 'logrocket'; LogRocket.init('app/id');
// Add to your HTML: <script src="https://cdn.lr-ingest.com/LogRocket.min.js"></script> <script>window.LogRocket && window.LogRocket.init('app/id');</script>
Hey there, want to help make our blog better?
Join LogRocket’s Content Advisory Board. You’ll help inform the type of content we create and get access to exclusive meetups, social accreditation, and swag.
Sign up nowBuild scalable admin dashboards with Filament and Laravel using Form Builder, Notifications, and Actions for clean, interactive panels.
Break down the parts of a URL and explore APIs for working with them in JavaScript, parsing them, building query strings, checking their validity, etc.
In this guide, explore lazy loading and error loading as two techniques for fetching data in React apps.
Deno is a popular JavaScript runtime, and it recently launched version 2.0 with several new features, bug fixes, and improvements […]
One Reply to "Tips for transpiling code from PHP 8.0 down to 7.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….