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

Coding in PHP 7.4 and deploying to 7.1 via Rector and GitHub Actions

12 min read 3577

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.

Why PHP 7.1

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.

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

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.

Supported PHP features

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 type
Functions:

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 names
Functions:

8.0 Union types
mixed pseudo type
static return type
Interfaces:

  • Stringable

Classes:

  • ValueError
  • UnhandledMatchError

Constants:

  • FILTER_VALIDATE_BOOL

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?

Transpilation inputs and outputs

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:

  • Typed properties, arrow functions, the null coalescing assignment operator, unpacking inside arrays, and the numeric literal separator from PHP 7.4
  • Reference assignments in array destructuring and flexible Heredoc syntax from PHP 7.3
  • The object return and param type from PHP 7.2

When 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.

Transpiling third-party dependencies

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.

Downgrade sets

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:

  • From PHP 7.4 to 7.3
  • From PHP 7.3 to 7.2
  • From PHP 7.2 to 7.1

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'; 

Loading WordPress

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"
  }
}

Dealing with 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/');
  }

Running Rector

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:

Running Rector on the Plugin
Running Rector on the plugin.

Testing the transpiled code

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.

Dealing with oddities

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.

Generating the asset for production via GitHub Actions

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/action-zip@v0.1.0
        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/action-zip@v0.1.0
        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:

The Generated Plugin Asset
Generated plugin asset.

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):

WordPress Running Our Transpiled Plugin
WordPress on PHP 7.1 running transpiled plugin.

It works!

Conclusion

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!

You come here a lot! We hope you enjoy the LogRocket blog. Could you fill out a survey about what you want us to write about?

    Which of these topics are you most interested in?
    ReactVueAngularNew frameworks
    Do you spend a lot of time reproducing errors in your apps?
    YesNo
    Which, if any, do you think would help you reproduce errors more effectively?
    A solution to see exactly what a user did to trigger an errorProactive monitoring which automatically surfaces issuesHaving a support team triage issues more efficiently
    Thanks! Interested to hear how LogRocket can improve your bug fixing processes? Leave your email:

    : 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 unifying all of them into a single mental model.

    2 Replies to “Coding in PHP 7.4 and deploying to 7.1 via…”

    1. 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

    Leave a Reply