PHP 7.2 Support!

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 the composer.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 the
TEST_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.

What’s next?

We have a number of initiatives we’re working on in the coming months:

  • Frank Brückner is working on a site
    refresh to both make the documentation and main sites more consistent in
    look-and-feel, as well as better support mobile browsers.

  • We continue to work on the Apigility on Expressive initiative. While many
    features were released with stable versions for Expressive 3, there’s still work
    to be done, including tooling support.

  • Aleksei Khudiakov has been working on a set of proposals
    for a PSR-7-based zend-mvc v4.

  • We want to work on tutorials and guides to help users make the most of
    Expressive, as well as migrate to Expressive from zend-mvc.

If you want to help out with any of these initiatives:

Source: Zend feed