PHP developers want to have access to the latest features of the language, but for various reasons, they may not be able to. It could be that the client’s server runs on an older version and can’t be upgraded, or the CMS must support legacy code, or the user base would shrink significantly, or others.
But there is a solution: we can use a transpiler to transform the code using the new syntax to the legacy one. Transpilers deliver the best of both worlds; developers can code using the latest features and generate an asset for production that works with previous versions of the language.
In my previous article, I introduced Rector, a reconstructor tool for PHP. Now let’s put it into practice. In this article, we’ll explore how to develop a WordPress plugin using PHP 7.4 code and release it containing code from PHP 7.1 and below via Rector and GitHub Actions.
I started transpiling my WordPress plugin as a consequence of WordPress deciding not to bump the minimum PHP version, which is currently 5.6. You may then wonder, why am I transpiling to PHP 7.1 and not to PHP 5.6?
There are two reasons for this. Firstly, Rector performs transformations based on rules, such as ArrowFunctionToAnonymousFunctionRector, which downgrades code from an arrow function from PHP 7.4 to an anonymous function from PHP 7.3 and below:
class SomeClass
{
public function run()
{
$delimiter = ",";
- $callable = fn($matches) => $delimiter . strtolower($matches[1]);
+ $callable = function ($matches) use ($delimiter) {
+ return $delimiter . strtolower($matches[1]);
+ };
}
}
From the roughly 20 downgrade rules implemented to date, only a handful are from PHP 7.1 to 7.0, and none from 7.0 to 5.6. So there is limited support for reaching 7.0, and no support yet for targeting 5.6.
That doesn’t mean that Rector cannot support PHP 5.6, but the work must be done. If the rules are eventually implemented (before WordPress bumps its minimum version to 7.1, otherwise they won’t be needed anymore), I could then target a lower PHP version.
The second reason concerns third-party PHP dependencies. These must be transpiled, too, alongside our application code, and doing so may involve significant effort.
For instance, if a dependency requires PHP 7.1, and I target PHP 7.1 for my application, then the dependency is directly supported and I do not need to transpile its code. But if I target PHP 7.0 or 5.6, then I do need to transpile it.
Transpiling third-party dependencies can become challenging because they are not under my control. Just browsing its code is not enough; I would need to do thorough research to make sure that all PHP 7.1 code in the dependency can be transpiled. A single feature that escapes my attention could well make the application fail on runtime.
In my case, my application has one dependency requiring PHP 7.2 and a few dozen requiring PHP 7.1 (more about this later on). Since I do not have unlimited resources, I chose to target PHP 7.1 and transpile one dependency than to target 7.0 and transpile dozens.
As a result, my WordPress plugin won’t be available to users running WordPress 5.6 and 7.0, but that’s a trade-off I’m happy with.
When stating that an application can now use PHP 7.4 code, that doesn’t necessarily mean it can use every single feature introduced to PHP 7.4. Rather, it can use only those features for which there is a Rector rule to downgrade them.
Moreover, not all features can be transpiled, and some features won’t be transpiled because of some reason or another.
For instance, among the new constants introduced in PHP 7.4, constants SO_LABEL
, SO_PEERLABEL
, and others are FreeBSD-specific socket options. That seems too specific, so I don’t expect anyone to implement a Rector rule for them.
As a result, the application will not fully support PHP 7.4 (if anyone does need constant SO_LABEL
, it won’t be there); instead, it can fully support PHP 7.1 and be enhanced with a set of features from among PHP 7.2, 7.3, and 7.4.
The list below lists down the currently supported features for releasing the application for PHP 7.1. This list (which is bound to expand as the community implements the remaining downgrade rules) also includes features backported by the Symfony polyfill packages:
PHP version | Features |
7.1 | Everything |
7.2 | object typeFunctions: Constants: |
7.3 | Reference assignments in list() /array destructuring (except inside foreach )Flexible Heredoc and Nowdoc syntax Functions: Exceptions: |
7.4 | Typed properties Arrow functions Null coalescing assignment operator Unpacking inside arrays Numeric literal separator strip_tags() with array of tag namesFunctions: |
8.0 | Union typesmixed pseudo typestatic return typeInterfaces:
Classes:
Constants:
Functions: |
Have you noticed that some PHP 8.0 features are already supported? As soon as PHP 8.0 is released sometime at the end of this year, you can immediately start using union types in your application code without dropping support for PHP 7.1… How cool is that?
I will use my own plugin GraphQL API for WordPress and its packages to demonstrate how to transpile a WordPress plugin via Rector.
The code in the plugin uses features from PHP 7.4, 7.3, and 7.2, namely:
object
return and param type from PHP 7.2When transpiling, these features are then converted to their equivalent code from PHP 7.1.
This table displays examples from the source code and what Rector converts them into when generating the asset for production:
PHP feature | Source code | Transpiled code |
Typed properties |
class ModuleTypeRegistry
{
- protected array $moduleTypeResolvers = [];
}
|
class ModuleTypeRegistry
{
+ /**
+ * @var array
+ */
+ protected $moduleTypeResolvers = [];
}
|
Arrow functions |
$modules = array_filter(
$modules,
- fn ($module) => !$this->getModuleResolver($module)->isHidden($module)
);
|
$modules = array_filter(
$modules,
+ function ($module) {
+ return !$this->getModuleResolver($module)->isHidden($module);
+ }
);
|
Null coalescing assignment operator |
-$fragments ??= $this->getFragments();
|
+$fragments = $fragments ?? $this->getFragments();
|
Unpacking inside arrays |
-return [
- ...$categories,
- [
- 'slug' => $this->getBlockCategorySlug(),
- 'title' => $this->getBlockCategoryTitle(),
- ],
-];
|
+return array_merge(
+ $categories, [[
+ 'slug' => $this->getBlockCategorySlug(),
+ 'title' => $this->getBlockCategoryTitle(),
+ ]]
+);
|
Numeric literal separator |
-$executionTime / 1_000_000
|
+$executionTime / 1000000
|
Reference assignments in list() /array destructuring |
-[&$vars] = $vars_in_array;
|
+$vars =& $vars_in_array[0];
|
Flexible Heredoc syntax |
-return <<<EOT
- # Welcome to GraphiQL
- #
- # GraphiQL is an in-browser tool for writing, validating, and
- # testing GraphQL queries.
- EOT;
|
+return <<<EOT
+# Welcome to GraphiQL
+#
+# GraphiQL is an in-browser tool for writing, validating, and
+# testing GraphQL queries.
+EOT;
|
object type in return |
-public function getInstance(string $class): object;
|
+/**
+ @return object
+ */
+public function getInstance(string $class);
|
object type in params |
-public function getID(object $resultItem)
{
$directive = $resultItem;
return $directive->getID();
}
|
+/**
+ * @param object $resultItem
+ */
+public function getID($resultItem)
{
$directive = $resultItem;
return $directive->getID();
}
|
The files come from two sources: the src/
folder and the vendor/
folder.
src/
is where the application code is stored, so it’s completely under my control. As such, I can guarantee that this code will only contain the supported PHP features described earlier on.
vendor/
contains all the dependencies (managed via Composer) both owned by me and by third parties. For my plugin, all the dependencies to transpile (from owners getpop
, pop-schema
, and graphql-by-pop
) are also mine, so once again, I can guarantee this code will only contain supported features.
The excluded paths correspond to included dependencies that I already know contain only PHP 7.1 and below code. So there’s nothing to transpile for them, and as such, I directly skip running Rector on them.
What about the third-party dependencies? Why am I not transpiling any of them?
Luckily, I haven’t needed to. Here’s why.
We need to find out whether the third-party dependencies must be transpiled to PHP 7.1.
The first step is to find out which dependencies require PHP 7.2 or above. For that, we install the Composer dependencies for production since that’s where we will run the transpiled code:
composer install --no-dev
Now we can obtain the list of dependencies that don’t support PHP 7.1 by running:
composer why-not php 7.1.33
Please notice that the constraint is on version 7.1.33
(which is the latest version of PHP 7.1) and not directly on 7.1
. That is because 7.1
is interpreted as 7.1.0
, so a package requiring version 7.1.3
would also fail.
For my plugin, running the command above produces these dependencies:
symfony/cache v5.1.6 requires php (>=7.2.5) symfony/cache-contracts v2.2.0 requires php (>=7.2.5) symfony/expression-language v5.1.6 requires php (>=7.2.5) symfony/filesystem v5.1.6 requires php (>=7.2.5) symfony/inflector v5.1.6 requires php (>=7.2.5) symfony/service-contracts v2.2.0 requires php (>=7.2.5) symfony/string v5.1.6 requires php (>=7.2.5) symfony/var-exporter v5.1.6 requires php (>=7.2.5)
So I had to inspect the source code for these eight packages to check why they require at least PHP 7.2.5 and find out whether that code could be transpiled.
Six packages (cache-contracts
, expression-language
, filesystem
, inflector
, service-contracts
, and string
) only use PHP 7.1 code and below. They have a requirement on PHP 7.2.5 only because one of their dependencies has this requirement.
I do not know (and I do not care) whether package symfony/var-exporter
, which is a dependency from symfony/cache
,contains PHP 7.2 code: it is referenced from classes that my plugin does not use (PhpArrayAdapter
and PhpFilesAdapter
), and because of PSR-4
and autoloading, no class from the package will be loaded on runtime.
Finally, package symfony/cache
does contain PHP 7.2 code, in class PdoAdapter
. I could transpile this code (there is the corresponding downgrade rule) but there is no need: my application doesn’t access class PdoAdapter
, and because of PSR-4
, it’s never loaded.
These eight packages are rather small, and PHP 7.2 introduced only a handful of new features, so searching for occurrences of PHP 7.2 code in them was not so hard. But having bigger packages, or targeting PHP versions with more features, would make the task more difficult.
Next, we define what sets or rules to apply on the code:
// here we can define what sets of rules will be applied $parameters->set(Option::SETS, [ // @todo Uncomment when PHP 8.0 released // SetList::DOWNGRADE_PHP80, SetList::DOWNGRADE_PHP74, SetList::DOWNGRADE_PHP73, SetList::DOWNGRADE_PHP72, ]);
Have you seen the commented SetList::DOWNGRADE_PHP80
line? On the same day that PHP 8.0 is released, just by uncommenting that line, my plugin can start using union types 😎.
Concerning the order in which sets are executed, the code must be downgraded from higher to lower version:
With the current rules, this doesn’t make a difference, but it would if the downgraded code were to be modified by another rule from a lower PHP version.
For instance, the null coalescing assignment operator ??=
introduced in PHP 7.4 is downgraded like this:
$array = [];
-$array['user_id'] ??= 'value';
+$array['user_id'] = $array['user_id'] ?? 'value';
Then, if downgrading all the way to PHP 5.6, the transpiled code with the null coalescing operator ??
must also be downgraded, like this:
$array = [];
-$array['user_id'] = $array['user_id'] ?? 'value';
+$array['user_id'] = isset($array['user_id']) ? $array['user_id'] : 'value';
Because WordPress doesn’t use Composer autoloading, we must provide the path to its source files, otherwise Rector will throw an error whenever encountering WordPress code (such as executing a WordPress function, extending from a class from WordPress, or others):
// Rector relies on autoload setup of your project; Composer autoload is included by default; to add more: $parameters->set(Option::AUTOLOAD_PATHS, [ // full directory __DIR__ . '/vendor/wordpress/wordpress', ]);
To download the WordPress source files, we add WordPress as a Composer dependency (but only for development), and we customize its location to vendor/wordpress/wordpress
. Our composer.json
will look like this:
{ "require-dev": { "johnpbloch/wordpress": ">=5.5" }, "extra": { "wordpress-install-dir": "vendor/wordpress/wordpress" } }
Just including the autoload path for WordPress may not be enough. For instance, when running Rector, I would get this error (which traces back to where my code references class WP_Upgrader
):
PHP Warning: Use of undefined constant ABSPATH - assumed 'ABSPATH' (this will throw an Error in a future version of PHP) in .../graphql-api-for-wp/vendor/wordpress/wordpress/wp-admin/includes/class-wp-upgrader.php on line 13
I didn’t dig deep into why this happens, but it seems that the WordPress code defining constant ABSPATH
(in wp-load.php
) was somehow not executed. So I just replicated this logic in my Rector config, pointing to where the WordPress source files are:
/** Define ABSPATH as this file's directory */ if (!defined('ABSPATH')) { define('ABSPATH', __DIR__ . '/vendor/wordpress/wordpress/'); }
The Rector configuration is set up, so let’s start transpiling some code!
To run Rector, on the root folder of the plugin we run:
vendor/bin/rector process --dry-run
We must use --dry-run
because we are downgrading code, and we don’t want to override the source files. The process without --dry-run
shall be executed within our continuous integration process when producing the asset for production (more on this later on).
For my plugin, Rector takes around 1 minute to process 16 downgrade rules on the 4,188 files contained in the specified path, after which it displays how the code from 173 files would be transformed:
Once we have produced transpiled the code, how do we know it works well? That is to say, if we are targeting PHP 7.1, how can we make sure that all pieces of code from PHP 7.2 and above have been downgraded?
The way I found is to use PHP 7.1 to run the downgraded code. If any PHP 7.2 or above code still lingers, and it is referenced, the PHP engine will not recognize it and throw an error.
I have implemented this solution with Travis as part of my continuous integration process. Whenever new code is pushed to the repo, it validates that it can be properly downgraded. To assert this, I just run PHPStan on the transpiled code; if the PHPStan process exits with no errors, it means that all transpiled code is compatible with PHP 7.1.
The solution, which produces these results (notice the transpiled code deletions in red, and additions in green), is implemented here:
language: php os: - linux dist: bionic php: - 7.4 jobs: include: - name: "Test downgrading" script: - vendor/bin/rector process - composer config platform-check false - composer dumpautoload - phpenv local 7.1 - vendor/bin/phpstan analyse -c phpstan.neon.dist src/ after_script: skip script: - vendor/bin/phpunit --coverage-text --coverage-clover=coverage.clover
Let’s see how this solution works.
We first downgrade the code via Rector by running vendor/bin/rector process
. Since the source files contain PHP 7.4 code, executing Rector must be done on PHP 7.4, or otherwise the PHP engine would throw an error when parsing the files.
Composer v2 (released barely a few days ago) introduced platform checks. Since composer.json
requires PHP 7.4, but we’ll be running PHP 7.1, we must disable these, or otherwise executing phpstan
will trigger an error. For that, we first execute composer config platform-check false
, and then composer dumpautoload
to remove file vendor/composer/platform_check.php
, which is where the validation happens.
Having downgraded the code, we switch the environment’s PHP version from 7.4 to 7.1. For this reason we use Ubuntu 18.04 LTS, Bionic as the build environment because it comes with PHP 7.1 preinstalled, and we can switch to PHP 7.1 by running phpenv local 7.1
.
Command vendor/bin/phpstan analyse -c phpstan.neon.dist src/
then runs PHPStan on the downgraded code. This process exiting with 0
means that the downgrading was successful, otherwise an error message will be shown pointing at the failing code.
My plugin uses the latest version of PHPUnit (version 9.4), which needs PHP 7.3 or above. Hence, this process cannot run PHPUnit or it will fail, which is why it’s skipped. Then, Travis must use a matrix to perform the different tests, and PHPUnit is executed on a separate run.
We may occasionally run across oddities that we may need to fix.
For instance, I run PHPStan on the source code to avoid potential bugs from type mismatches (using the strictest mode, level 8
). PHPStan currently has a bug in which passing an anonymous function to array_filter
may throw a nonexistent error, but passing an arrow function instead works well.
As a consequence, PHPStan’s behavior on the source code containing arrow functions, and on its transpiled version containing anonymous functions, may differ. For my plugin, PHPStan would not show any error for this arrow function:
$skipSchemaModuleComponentClasses = array_filter( $maybeSkipSchemaModuleComponentClasses, fn ($module) => !$moduleRegistry->isModuleEnabled($module), ARRAY_FILTER_USE_KEY );
But it would throw an error for its transpiled code:
$skipSchemaModuleComponentClasses = array_filter( $maybeSkipSchemaModuleComponentClasses, function ($module) use ($moduleRegistry) { return !$moduleRegistry->isModuleEnabled($module); }, ARRAY_FILTER_USE_KEY );
To fix it, I configured PHPStan to ignore the error (for the downgraded code) and disable failure in case of unmatched errors (for the source code):
parameters: reportUnmatchedIgnoredErrors: false ignoreErrors: - message: '#^Parameter \#1 \$module of method GraphQLAPI\\GraphQLAPI\\Registries\\ModuleRegistryInterface::isModuleEnabled\(\) expects string, array\<int, class-string\> given\.$#' path: src/PluginConfiguration.php
As a takeaway, we must always double-check that the source code and its transpiled version produce the same behavior when running processes on them so as to avoid unpleasant surprises.
We’re almost done. By now, we have configured the transpiling and tested it. All there is left to do is to transpile the code when generating the asset for production. This asset will become the actual WordPress plugin, to be distributed for installation.
Since my plugin code is hosted on GitHub, I have created a GitHub action which, upon tagging the code, will generate the transpiled asset. The action has this content:
name: Generate Installable Plugin and Upload as Release Asset on: release: types: [published] jobs: build: name: Build, Downgrade and Upload Release runs-on: ubuntu-latest steps: - name: Checkout code uses: actions/checkout@v2 - name: Downgrade code for production (to PHP 7.1) run: | composer install vendor/bin/rector process sed -i 's/Requires PHP: 7.4/Requires PHP: 7.1/' graphql-api.php - name: Build project for production run: | composer install --no-dev --optimize-autoloader mkdir build - name: Create artifact uses: montudor/[email protected] with: args: zip -X -r build/graphql-api.zip . -x *.git* node_modules/\* .* "*/\.*" CODE_OF_CONDUCT.md CONTRIBUTING.md ISSUE_TEMPLATE.md PULL_REQUEST_TEMPLATE.md rector.php *.dist composer.* dev-helpers** build** - name: Upload artifact uses: actions/upload-artifact@v2 with: name: graphql-api path: build/graphql-api.zip - name: Upload to release uses: JasonEtco/upload-to-release@master with: args: build/graphql-api.zip application/zip env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
I have already documented on my blog most steps from this action: how it’s triggered, how it creates a new .zip
file containing all the Composer dependencies, and how it is uploaded as a release asset to the GitHub repo.
The only new addition is the step to downgrade the code, which happens here:
- name: Downgrade code for production (to PHP 7.1) run: | composer install vendor/bin/rector process sed -i 's/Requires PHP: 7.4/Requires PHP: 7.1/' graphql-api.php
Please notice how composer install
is executed twice within the action: a first time without --no-dev
because Rector is installed as a dev dependency, and then again with --no-dev
to remove all dev dependencies from under vendor/
before generating the asset for production.
After installing the dependencies, we run vendor/bin/rector process
to transpile the code. There is no --dry-run
here, so Rector will not just display the transformations, but also apply them on the input files.
Then, we must modify the Requires PHP
header in the plugin’s main file (which WordPress relies on to validate whether the plugin can be installed) from 7.4
to 7.1
. We do this by executing sed -i 's/Requires PHP: 7.4/Requires PHP: 7.1/' graphql-api.php
.
This last step may seem a detail. After all, I could define header Requires PHP: 7.1
already in the source code. However, we can also install the plugin directly from the repo (indeed, that is the case for development). So, for consistency, both the source code and the generated .zip
file plugin must indicate their own respective PHP versions.
Finally, when creating the .zip
file, we should exclude file rector.php
(along with all other files to exclude):
- name: Create artifact uses: montudor/[email protected] with: args: zip -X -r build/graphql-api.zip . -x rector.php ...
When this GitHub action is triggered, it will generate the plugin asset graphql-api.zip
and upload it to the releases page:
Let’s check that the asset generation was successful. For that, I download the transpiled plugin graphql-api.zip
, install it in a WordPress site running PHP 7.1, and then invoke its functionality (in this case, the execution of a GraphQL query):
It works!
The plugin was coded using features from PHP 7.4, and it can be installed on WordPress running PHP 7.1. Objective achieved 🙏.
Transpiling our PHP code gives us the chance to decouple development of the application from the application itself, so we can use the latest PHP features even if our clients or CMS can’t support them. PHP 8.0 is just around the corner. Want to use union types? Now you can do it!
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 […]
2 Replies to "Coding in PHP 7.4 and deploying to 7.1 via Rector and GitHub Actions"
Why use Travis for running PHPStan, you can run that in GitHub Actions as well.
Yep, no particular reason, I had my CI already working with Travis so I just went along with it. If I had to create it from scratch, I would’ve only used GitHub Actions