A GitHub Workflow to Check the Compatibility of Your PHP Package with a Range of Dependency Versions
Tags: githubactions, php, compatibilitytesting, composer
Published: 2022-07-05 08:05:28

A common aspect of a PHP developer's job is to deal with [Composer](https://getcomposer.org/) 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](https://docs.github.com/en/actions/using-workflows).

## In this post

* Complete workflow
* Breakdown
* Closing thoughts
* Resources

## 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](https://github.com/osteel/php-compatibility-demo) for reference.

{% gist https://gist.github.com/osteel/9b2dbab1db704ce353bbc5452abc5764 %}

## 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](https://github.com/new) 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](https://docs.github.com/en/billing/managing-billing-for-github-actions/about-billing-for-github-actions) (unless you've got a paid account, in which case feel free to keep the repository private).

> _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_](https://getcomposer.org/doc/04-schema.md#type) for the type of project, [_stable_](https://getcomposer.org/doc/04-schema.md#minimum-stability) 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](https://github.com/moneyphp/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](https://en.wikipedia.org/wiki/ISO_4217#Active_codes) 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](https://github.com/osteel/php-compatibility-demo) 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](https://phpunit.de/) as a development dependency:

```
$ composer require --dev phpunit/phpunit
```

{% gist https://gist.github.com/osteel/ae31d33b03ef8ce7475295e1eedaf013 %}

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`](https://www.php.net/manual/en/function.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":

{% gist https://gist.github.com/osteel/46d7b66c160a5af9c988ba9cd1281cbe %}

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

{% gist https://gist.github.com/osteel/d89d86aadde4c9b75f705e998ca485b5 %}

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

* [Checkout](https://github.com/actions/checkout);
* [Setup PHP](https://github.com/shivammathur/setup-php);
* [Composer Install](https://github.com/ramsey/composer-install).

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](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/n6ne8wzi9q2o4pnk8u0z.png)

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+](https://github.com/moneyphp/money/blob/v4.0.0/composer.json#L27) 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+](https://github.com/moneyphp/money/blob/3.x/composer.json#L27), 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:

{% gist https://gist.github.com/osteel/cd4871d57247700931e00c706e9dee1e %}

The main change here is the addition of the `strategy` section. A [strategy](https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions#jobsjob_idstrategy) allows us to define a [matrix](https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions#jobsjob_idstrategymatrix) 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](https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions#jobsjob_idstrategyfail-fast) 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](https://www.php.net/releases/8.1/en.php) 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](https://github.com/shivammathur/setup-php#php-version-required) 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](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/8g87qjjjvj5szwqq3paq.png)

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

![Failed intermediate workflow – PHP 7.4](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/xwi0xluysj52fu13fjm6.png)

Click on it and expand the _Run PHPUnit_ logs:

![Failed intermediate workflow – PHP 7.4 logs](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/0q9ve3kfhglwm6z6qgk5.png)

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](https://github.com/moneyphp/money/blob/master/CHANGELOG.md), the reason appears to be because the `Money@isSameCurrency` method only added support for multiple arguments [from v4](https://github.com/moneyphp/money/blob/v4.0.0/CHANGELOG.md#changed). 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](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/n6zi0zgotvpv13zd5uh5.png)

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](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/0mgpm3iax3sg5x7s2m4v.png)

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:

{% gist https://gist.github.com/osteel/9b2dbab1db704ce353bbc5452abc5764 %}

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](https://github.com/ramsey/composer-install#dependency-versions) 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](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/q979alkuiyqpbcin5txv.png)

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](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/po27fwfgwe8gt6t4u28d.png)

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](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/7v7umjjsnm9j3pp694j1.png)

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](https://github.com/moneyphp/money/blob/v4.0.0/CHANGELOG.md#305---2017-04-26).

We now have two ways to fix this – either we update the `CurrencyHelper@numericCode` method and manually implement [the change](https://github.com/moneyphp/money/compare/v3.0.4...v3.0.5#diff-0631517d385a0d04aa9dbc68ee7e92163b406e084208483ab1837c3ace4bb7f1), or we bump the minimum supported version to 3.0.5.

Since this version is a small [patch](https://semver.org/#summary), 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](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/im0y0cf90b4t00m3ql2g.png)

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](https://github.com/osteel/openapi-httpfoundation-testing) and, later on, when I started exploring [building for the console with PHP](https://tech.osteel.me/posts/how-to-build-and-distribute-beautiful-command-line-applications-with-php-and-composer).

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

* [Article repository](https://github.com/osteel/php-compatibility-demo)
* [GitHub Actions documentation](https://docs.github.com/en/actions)
* [GitHub Actions workflows documentation](https://docs.github.com/en/actions/using-workflows)
* [Checkout (GitHub Action)](https://github.com/actions/checkout)
* [Setup PHP (GitHub Action)](https://github.com/shivammathur/setup-php)
* [Composer Install (GitHub Action)](https://github.com/ramsey/composer-install)
* [PHP Money library](https://github.com/moneyphp/money)