osteel's blog Web development resources

A GitHub workflow to check the compatibility of your PHP package with a range of dependency versions

Been here before?

You can also subscribe to the RSS or Atom feed, or follow me on Twitter.

Successful workflow run

A common aspect of a PHP developer's job is to deal with Composer dependencies. We use the work of others as Lego bricks to build our own projects, making the most of the beautiful thing that is the open-source movement.

As typical end-users of these community efforts, we maintain a list of dependencies whose versions are pinned through the composer.lock file. We don't need to think about supporting other versions of these dependencies, just as we don't need to think about accommodating a range of PHP versions – we work within the constraints of our application's target environments, which are known and expected to be stable over time.

Things are different for open-source software maintainers. They are largely agnostic to the constraints of their users' environments, so the best they can do is make sure their libraries are compatible with as many PHP and dependency versions as possible.

This is where compatibility testing is crucial. Open-source maintainers need to ensure that any change made to the code will work with all the versions listed in composer.json. Doing so manually is unpractical, so they resort to test coverage and automated scripts.

This post explores one such way of automating compatibility testing, using a combination of test coverage and a GitHub Actions workflow.

In this post

Complete workflow

Here is the final version of the workflow. If you're just here for the code, feel free to copy and reuse the below as much as you please. Otherwise keep reading for a progressive breakdown of the file, supported by an example project.

You can also check out this article's repository for reference.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
name: CI

on:
  push:
    branches: [main]
  pull_request:
    branches: [main]

jobs:

  phpunit:
    name: PHPUnit
    runs-on: ubuntu-latest

    strategy:
      fail-fast: false
      matrix:
        php-version:
          - "7.4"
          - "8.0"
          - "8.1"
        dependency-versions:
          - "lowest"
          - "highest"

    steps:
      - name: Checkout
        uses: actions/checkout@v3

      - name: Setup PHP
        uses: shivammathur/setup-php@v2
        with:
          php-version: ${{ matrix.php-version }}
          coverage: none

      - name: Install composer dependencies
        uses: "ramsey/composer-install@v2"
        with:
          dependency-versions: ${{ matrix.dependency-versions }}

      - name: Run PHPUnit
        run: vendor/bin/phpunit tests

Breakdown

The following is a full breakdown of the workflow. We will build it up incrementally, using an example project that we will update as we expand the range of supported dependency versions.

Initial setup

This step is optional, but if you're a "learn through practice" kind of person, you may want to build this example project on your machine and update it as we go. If you don't, you may want to read this section anyway, as it contains some relevant information.

If you do, go ahead and create a new empty GitHub repository now. Call it php-compatibility-demo (or anything else if you feel inspired) and make sure it's public so you can enjoy the free GitHub Actions allowance (unless you've got a paid account, in which case feel free to keep the repository private).

Development setup The below assumes that your development setup supports PHP 8.0+ and that you've got access to a terminal with Composer and Git.

Clone the repository on your development machine and initialise a new Composer project:

$ git clone php-compatibility-demo
$ cd php-compatibility-demo
$ composer init

The last command will ask you a series of questions and generate a composer.json file at the end. Most questions don't matter in the context of this article, but make sure to select library for the type of project, stable for the minimum stability, and to leave the default values for the PSR-4 autoload bit.

Once that's done, add /vendor/ and composer.lock to the .gitignore file:

/vendor/
composer.lock

This is the first major difference from regular company projects, where developers will usually commit the composer.lock file so the application is consistent across environments. Here we embrace inconsistency instead, and let Composer figure out what dependency versions to use based on the constraints of client environments.

In this context, the GitHub workflow is yet another client environment, and we don't want it to pick up a composer.lock file that will force the installation of specific versions while our goal is to test a whole range of them.

Library and test coverage

With that in place, let's talk about our test project for a minute. We are going to build a small library on top of Money, a popular PHP package facilitating the manipulation of monetary values and currencies.

Our library will consist of a single helper class – CurrencyHelper.php – that will simplify working with currencies. It will expose two methods – one to make sure that a bunch of Money objects have the same currency, and another one returning the numeric ISO 4217 code of a Money object.

You will soon realise that this library is pretty pointless, but it makes up a good example to support this article, so I trust you will turn a blind eye.

Here's the file structure you should get at the end of this section (remember that you can also refer to this article's repository at any time for comparison):

php-compatibility-demo/
├── src/
│   └── CurrencyHelper.php
├── tests/
│   └── CurrencyHelperTest.php
├── vendor/
├── .gitignore
├── composer.json
└── composer.lock

Let's instal the Money library:

$ composer require moneyphp/money:^4.0

Note that we intentionally import v4 here, even though it may not be the latest version when you're reading this.

Let's also instal PHPUnit as a development dependency:

$ composer require --dev phpunit/phpunit

Now create the src/CurrencyHelper.php file:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
<?php

namespace Osteel\PhpCompatibilityDemo;

use Money\Currencies\ISOCurrencies;
use Money\Money;

class CurrencyHelper
{
    public function isSame(Money ...$amounts): bool
    {
        if (count($amounts) === 1) {
            return true;
        }

        $first = array_shift($amounts);

        return $first->isSameCurrency(...$amounts);
    }

    public function numericCode(Money $amount): int
    {
        return (new ISOCurrencies())->numericCodeFor($amount->getCurrency());
    }
}

If you're also building this example library on your machine, don't forget to update the vendor name in the namespace at the top.

The isSame method takes any number of Money objects and returns a boolean indicating whether they share the same currency. The way it works is that it removes the first element from the array (using array_shift) and then compares this element's currency with that of the other ones, using the isSameCurrency method from the Money class.

The second method – numericCode – takes a Money object as its only parameter and returns its currency's numeric ISO code, using the numericCodeFor method from the ISOCurrencies helper class (which is part of the Money library).

And here is the content for tests/CurrencyHelperTest.php, the class testing the behaviour of our "library":

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
<?php

namespace Osteel\PhpCompatibilityDemo\Tests;

use Money\Money;
use Osteel\PhpCompatibilityDemo\CurrencyHelper;
use PHPUnit\Framework\TestCase;

class CurrencyHelperTest extends TestCase
{
    public function testItReturnsWhetherCurrenciesAreTheSame()
    {
        $helper = new CurrencyHelper();

        $amount1 = Money::EUR(10);
        $amount2 = Money::EUR(20);
        $amount3 = Money::EUR(15);
        $amount4 = Money::USD(10);

        $this->assertTrue($helper->isSame($amount1, $amount2));
        $this->assertTrue($helper->isSame($amount1, $amount2, $amount3));
        $this->assertFalse($helper->isSame($amount1, $amount4));
        $this->assertFalse($helper->isSame($amount1, $amount2, $amount4));
    }

    public function testItReturnsNumericCode()
    {
        $helper = new CurrencyHelper();

        $this->assertEquals(978, $helper->numericCode(Money::EUR(10)));
        $this->assertEquals(840, $helper->numericCode(Money::USD(10)));
    }
}

These tests will allow us to confirm whether our library works with various PHP and dependency versions later on. Again, don't forget to update the vendor's name at the top.

Let's check that the tests are passing:

$ ./vendor/bin/phpunit tests

If they do, we can move on to the workflow.

Basic workflow

Let's start with a simple GitHub Actions workflow whose only job will be to run the test suite. Basically what we just did, but in an automated way.

We need to create a new .github folder at the root of the project, itself containing a workflows folder in which we add a file named ci.yml. These directories are GitHub conventions – that's where the platform expects to find our workflows, ci.yml being one of them.

This is the updated file structure:

php-compatibility-demo/
├── .github/
│   └── workflows/
│       └── ci.yml
├── src/
│   └── CurrencyHelper.php
├── tests/
│   └── CurrencyHelperTest.php
├── vendor/
├── .gitignore
├── composer.json
└── composer.lock

And here's the content of ci.yml:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
name: CI

on:
  push:
    branches: [main]
  pull_request:
    branches: [main]

jobs:

  phpunit:
    name: PHPUnit
    runs-on: ubuntu-latest

    steps:
      - name: Checkout
        uses: actions/checkout@v3

      - name: Setup PHP
        uses: shivammathur/setup-php@v2
        with:
          coverage: none

      - name: Install composer dependencies
        uses: "ramsey/composer-install@v2"

      - name: Run PHPUnit
        run: vendor/bin/phpunit tests

Let's break it down quickly – we first give the workflow a name ("CI" stands for "Continuous Integration"), and then define the conditions on which it should trigger (here, any changes pushed to the main branch, or any pull request opened towards it).

We then list the jobs to be executed. There's only one in our case – running the test suite. We call this job "PHPUnit" and specify that it will run on the latest Ubuntu version.

Then we define the steps making up the job – checking out the code, setting up PHP, installing the Composer dependencies and, finally, running the test suite. The first three steps are using popular GitHub Actions (see the uses keys):

We don't need a GitHub Action for the last step as it is a simple command.

Save the file, commit and push. After a few seconds, you should see a screen similar to this under the Actions tab of your GitHub repository:

Successful basic workflow

You can click on the workflow run to see the logs

And that's it for the first version of our workflow. It's a decent first step already, as the workflow will trigger any time some new code is pushed to/a pull request is opened towards main, automatically reporting any breaking changes.

But so far we're only testing a single set of dependency versions – whatever Composer decides is best when the workflow reaches the Install composer dependencies step.

Let's go one step further.

Dependency ranges

Say we want to support both PHP 7.4+ and PHP 8.0+. Instead of letting Composer do its best based on what software version is available on the client environment, we are going to tip it off by explicitly listing the PHP versions our library is compatible with.

Open composer.json and update the require section as follows:

"require": {
    "php": "^7.4|^8.0",
    "moneyphp/money": "^4.0"
},

We're now supporting both PHP 7.4+ and PHP 8.0+. But wait, that's not going to work – the Money library requires PHP 8.0+ for its v4, so if we want to add support for PHP 7.4+, we also need to include an older version of Money.

Let's update composer.json again:

"require": {
    "php": "^7.4|^8.0",
    "moneyphp/money": "^3.0|^4.0"
},

We're now also supporting both Money v3 and v4. As Money v3 needs PHP 5.6+, we should be compatible with PHP 7.4 as well.

As we've updated composer.json, let's make sure our local composer.lock is up to date with the following command:

$ composer update --lock

We now need to update our workflow to ensure the test suite will run for both versions of PHP:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
name: CI

on:
  push:
    branches: [main]
  pull_request:
    branches: [main]

jobs:

  phpunit:
    name: PHPUnit
    runs-on: ubuntu-latest

    strategy:
      fail-fast: false
      matrix:
        php-version:
          - "7.4"
          - "8.0"
          - "8.1"

    steps:
      - name: Checkout
        uses: actions/checkout@v3

      - name: Setup PHP
        uses: shivammathur/setup-php@v2
        with:
          php-version: ${{ matrix.php-version }}
          coverage: none

      - name: Install composer dependencies
        uses: "ramsey/composer-install@v2"

      - name: Run PHPUnit
        run: vendor/bin/phpunit tests

The main change here is the addition of the strategy section. A strategy allows us to define a matrix for our jobs – a way to run the steps using different configurations.

But let's have a quick look at fail-fast first – by setting this property to false, we make sure the steps will run for all configurations and won't be interrupted even if one of them fails. We want comprehensive feedback for all PHP versions here, so we can fix any issue that comes up.

Then comes the matrix section, composed of a single php-version key, which is an array of values. What that does is that for each successive run of the steps, the php-version key will be assigned a new value from the array, until we reach the end of the array.

Note that we've also listed PHP 8.1 in there, as there are some notable differences with PHP 8.0.

We can see the php-version key being referenced further down, in the Setup PHP step. We added a new php-version key under the with section – that's where we assign the value of the current run (${{ matrix.php-version }}), using a configuration option from the Setup PHP GitHub Action.

Let's try this out – commit and push the changes and head over to the repository's Actions tab again. Give it a few seconds – you should see that this time, the run has failed:

Failed intermediate workflow

Click on the workflow run – there was an issue with the PHP 7.4 job:

Failed intermediate workflow – PHP 7.4

Click on it and expand the Run PHPUnit logs:

Failed intermediate workflow – PHP 7.4 logs

Interesting. One of our test assertions is failing on line 23:

$this->assertFalse($checker->isSame($amount1, $amount2, $amount4));

Looking at the Money library's changelog, the reason appears to be because the Money@isSameCurrency method only added support for multiple arguments from v4. We know that this version can only be used with PHP 8.0+, which explains why Composer instals Money v3 when it detects PHP 7.4.

To fix the issue, we need to change the way the isSame method works in CurrencyHelper.php:

public function isSame(Money ...$amounts): bool
{
    if (count($amounts) === 1) {
        return true;
    }

    $first = array_shift($amounts);

    foreach ($amounts as $amount) {
        if (! $first->isSameCurrency($amount)) {
            return false;
        }
    }

    return true;
}

Instead of passing all the remaining elements of the $amounts array to isSameCurrency at once, we now loop through them and compare their currency to that of the first element, one by one.

Let's make sure we haven't broken our library before pushing our changes:

$ ./vendor/bin/phpunit tests

Commit and push and check the Actions tab again:

Successful intermediate workflow

The error is gone!

So it appears our library is now compatible with both PHP 7.4+ and PHP 8.0+.

Or is it?

Testing lower dependency versions

Let's take a look at the logs again. Click on the latest successful workflow run and enter the PHPUnit (7.4) job from the left-hand side. Expand the Install composer dependencies logs and look for the line referencing the Money library.

It looks like Composer is picking the latest minor version by default (v3.3.1 at the time of writing):

Composer picks the latest version

The issue is we want our library to be compatible with previous versions as well – how can we force Composer to instal the lowest version – v3.0.0?

We need to update ci.yml again:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
name: CI

on:
  push:
    branches: [main]
  pull_request:
    branches: [main]

jobs:

  phpunit:
    name: PHPUnit
    runs-on: ubuntu-latest

    strategy:
      fail-fast: false
      matrix:
        php-version:
          - "7.4"
          - "8.0"
          - "8.1"
        dependency-versions:
          - "lowest"
          - "highest"

    steps:
      - name: Checkout
        uses: actions/checkout@v3

      - name: Setup PHP
        uses: shivammathur/setup-php@v2
        with:
          php-version: ${{ matrix.php-version }}
          coverage: none

      - name: Install composer dependencies
        uses: "ramsey/composer-install@v2"
        with:
          dependency-versions: ${{ matrix.dependency-versions }}

      - name: Run PHPUnit
        run: vendor/bin/phpunit tests

Note that we've added a new key to our matrix – dependency-versions. It is also an array, with two values – lowest and highest.

This time we're taking advantage of a configuration option from the Composer Install GitHub Action, allowing us to force Composer to either use the lowest or highest versions of the dependencies.

We can see the dependency-versions key being used further down, in the Install composer dependencies step. We added a new with section to it, with a single dependency-versions key to which we assign the current value from the matrix (${{ matrix.dependency-versions }}).

When that value is lowest, the Composer Install GitHub Action will run this command behind the scenes:

$ composer update --prefer-lowest --prefer-stable

What's the consequence of adding a new array of values to our matrix? The answer is that it will trigger as many step runs as there are combinations from both arrays.

Let's see it in action – commit and push the new workflow, and check the Actions tab again:

Failed advanced workflow

Looks like there was an issue with the new run. If we click on it, we'll see that the test suite was run for each PHP and dependency version, as well as for each value from the dependency-versions array.

But all the runs using the lowest value have failed:

Failed advanced workflow – lowest dependencies

Let's find out why. Click on the PHPUnit (7.4, lowest) job and expand the Run PHPUnit logs:

Failed advanced workflow – lowest dependencies logs

The error is the following:

Call to undefined method Money\Currencies\ISOCurrencies::numericCodeFor()

If we take a look at the Money library's changelog again, we can see that this method was added with v3.0.5.

We now have two ways to fix this – either we update the CurrencyHelper@numericCode method and manually implement the change, or we bump the minimum supported version to 3.0.5.

Since this version is a small patch, it feels reasonable to do the latter.

Let's update composer.json one last time:

"require": {
    "php": "^7.4|^8.0",
    "moneyphp/money": "^3.0.5|^4.0"
},

Commit and push, and wait for the workflow run to finish:

Successful advanced workflow

No more errors!

For good measure, let's make sure our local composer.lock is in sync with composer.json again:

$ composer update --lock

Closing thoughts

Compatibility testing highlights once again the importance of having good test coverage for our applications. Even if we don't need to support a range of dependency versions, whenever we find ourselves in need to update one of them, having a strong test suite gives us the confidence that it won't break some parts of our code. That also goes for upgrading to a newer PHP version.

But then again, most PHP developers don't have to think about this too much. I personally started to look into compatibility testing more seriously when I created my first open-source library and, later on, when I started exploring building for the console with PHP.

Yet you may eventually find yourself in a situation where you need to build and provide a PHP package with limited knowledge of target environments. Be it in the context of a private company or as a way to contribute back to the community, when that day comes it may be useful to have this kind of guide handy.

Resources

Enjoying the content?

You can also subscribe to the RSS or Atom feed, or follow me on Twitter.

Last updated by osteel on :: [ github workflow composer compatibility-testing ]

Comments