With Expressive 3 complete, we were able to turn our sights on another important initiative: PHP 7.2 support across all components and Apigilty modules.
The short story is: as of today, that initiative is complete! If you are using the Zend Framework MVC framework, Expressive, or Apigility, or any of the ZF components standalone, you should be able to perform a composer update
to get versions that support PHP 7.2.
The full story is much longer.
HOW WE GOT THERE
The PHP project does a pretty stellar job of preserving backwards compatibility. Some might say they do it to a fault, being averse to any changes that might cause breakage for users, even if the change fixes bad behavior on the part of the language.
Interestingly, there have been a ton of initiatives to tighten up the language and have it behave more predictably. Unfortunately, we, and a number of projects on which we depend, were bit by some of these efforts that went into PHP 7.1 and 7.2.
One in particular was problematic.
Let’s say you have a class such as the following:
class SomeContainer
{
public function get($name, array $options = null)
{
}
}
Next, we’ll have an extension to that class that overrides that method and changes the default value:
class AContainerExtension extends SomeContainer
{
public function get($name, array $options = [])
{
}
}
These should be fine, right? Wrong.
Starting in 7.1.0, the above emits an E_WARNING
due to incompatible signatures. This is because PHP 7.1 adds nullable types, and considers the first signature equivalent to a nullable array.
The problem is that PHPUnit, on seeing an E_WARNING
, creates an error status for the test in which it is raised.
There were a number of other minor changes such as deprecated APIs that also affected our code, often leading to unexpected test errors. Technically, the code likely could run, but not without emitting deprecation notices and/or warnings — and our goal is to run cleanly, so that users can see only the warnings pertinent to their own application code.
On top of this, a number of PHPUnit classes exhibited similar behaviors, which meant that under PHP 7.2, with the versions of PHP we were using, we could not verify that our code could work under that version.
The upshot for us is that testing against 7.2 wasn’t as easy as just adding another PHP version to the test matrix. We also had to find a set of different PHP versions that we could test against (for instance, PHPUnit 6 and 7 require PHP 7 versions, but we also still support PHP 5.6 in many of our components and modules), figure out how to get Travis-CI to install a PHPUnit version appropriate to the PHP version we were testing in, and ensure that the PHPUnit features we were using worked across all PHPUnit versions against which we might test.
Thankfully, we had a secret weapon: Michał Bundyra (@MichalBundyra on twitter). Michał spent a fair bit of time this past year developing increasingly effective approaches to this sort of problem, and, once the 7.2 initiative was announced, jumped in and created patches for almost every single component and module we ship.
Seriously, this was work above and beyond anything I can reasonably expect of a volunteer. Go thank him, already!
OUR APPROACH
The approach Michał developed involves two things. First, a range of known-good PHPUnit dependencies, and, second, a set of configuration for Travis-CI that will allow installing the appropriate dependencies.
First, we use the following constraints for PHPUnit in our composer.json
files when both PHP 5.6 and PHP 7 versions are required:
"phpunit/phpunit": "^5.7.21 || ^6.3 || ^7.1",
These allow us to use the 5.7 series for PHP 5.6, and either the 6.3 or 7.1 series when under other PHP versions.
We also commit our composer.lock
file. I’ll show why in a moment.
Next, we use configuration similar to the following with Travis-CI:
sudo: false
language: php
cache:
directories:
- $HOME/.composer/cache
env:
global:
- COMPOSER_ARGS="--no-interaction"
matrix:
include:
- php: 5.6
env:
- DEPS=lowest
- php: 5.6
env:
- DEPS=locked
- LEGACY_DEPS="phpunit/phpunit zendframework/zend-code"
- php: 5.6
env:
- DEPS=latest
- php: 7
env:
- DEPS=lowest
- php: 7
env:
- DEPS=locked
- CS_CHECK=true
- LEGACY_DEPS="phpunit/phpunit-mock-objects phpspec/prophecy zendframework/zend-code"
- php: 7
env:
- DEPS=latest
- php: 7.1
env:
- DEPS=lowest
- php: 7.1
env:
- DEPS=locked
- TEST_COVERAGE=true
- php: 7.1
env:
- DEPS=latest
- php: 7.2
env:
- DEPS=lowest
- php: 7.2
env:
- DEPS=locked
- php: 7.2
env:
- DEPS=latest
before_install:
- if [[ $TEST_COVERAGE != 'true' ]]; then phpenv config-rm xdebug.ini || return 0 ; fi
install:
- travis_retry composer install $COMPOSER_ARGS --ignore-platform-reqs
- if [[ $LEGACY_DEPS != '' ]]; then travis_retry composer update $COMPOSER_ARGS --with-dependencies $LEGACY_DEPS ; fi
- if [[ $DEPS == 'latest' ]]; then travis_retry composer update $COMPOSER_ARGS ; fi
- if [[ $DEPS == 'lowest' ]]; then travis_retry composer update --prefer-lowest --prefer-stable $COMPOSER_ARGS ; fi
- stty cols 120 && composer show
script:
- vendor/bin/phpunit
- if [[ $CS_CHECK == 'true' ]]; then vendor/bin/phpcs ; fi
Let’s break that down.
We set up a few things up front for all builds: we’re using dockerized php jobs (sudo: false
, language: php
), we’re caching composer metadata between builds (which greatly speeds up the installation process!), and defining our default composer
arguments.
From there, we define our test matrix. Each job in the matrix includes:
- The PHP version we are testing against.
- Environment variables for that specific build.
You’ll notice we have three jobs for each PHP version, corresponding to the following environment variables:
DEPS=lowest
DEPS=locked
DEPS=latest
These variables are indicating how we want to install dependencies:
locked
indicates we want to use those specified in thecomposer.lock
file.lowest
means we want to test against the lowest stable dependencies we allow.latest
indicates we want to test against the latest available dependences we allow.
This approach allows us to determine:
- When we start using features from a library that are not present in the earliest version we have indicated we support. If the
lowest
tests fail, we likely either need to change what part of a third-party API we are consuming, or bump the minimum supported version of that dependency. - When a library has introduced a BC break in a more recent release than we tested against previously. In such cases, we can try and find a way to make our own usage of that library forwards-compatible with the new version; create an issue notifying the developer(s) of that library of the BC break; or change our dependencies to not allow the newer version.
Additionally, some jobs have more variables they define:
CS_CHECK
will tell the job whether or not to run CS checks. (We also often define an env variable for running coverage reports.)LEGACY_DEPS
allows us to specify dependencies we need to update after initial installation. More on that in the coming paragraphs.
We also disable xdebug unless we’re running coverage reports. This speeds up Composer operations as well as running unit tests. I have the
before_install
script detailed above, but do not define any environments with theTEST_COVERAGE
variable set, nor demonstrate how we use it to run reports.
When we hit the install
section is when the “magic” happens. The first thing we do is an install from the lockfile. When we do so, we pass the --ignore-platform-reqs
option, as we cannot guarantee that the dependencies in the lockfile will work for the current PHP version being used.
We then check to see if LEGACY_DEPS
is non-empty. If so, we do a composer update --with-dependencies
, passing the value of LEGACY_DEPS
as the packages to update. This allows us to use the lockfile on locked
versions, but then get platform-specific dependencies for libraries where we know that what’s in the lockfile may not work on all platforms.
Next, we check for DEPS=latest
, running composer update
, and DEPS=lowest
, running composer update --prefer-lowest --prefer-stable
.
Finally, we display what dependencies were installed, along with their versions. We use the construct stty cols 120
to set the display columns, as otherwise composer
will not detect a TTY, and spit out only the dependencies, with no associated version.
The beauty of this approach is that we are able to use it almost verbatim across our repositories, with only minor changes to which LEGACY_DEPS
we need, and which versions need them. Having multiple tests per version, spanning a range of dependencies, has allowed us to identify and solve problems arising from libraries we consume quickly.
This approach allowed us to run tests under PHP 7.2, fix any issues identified, and finally release new versions that fully support PHP 7.2.