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
- 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 for reference.
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).
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
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":
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
:
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:
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:
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:
Click on the workflow run – there was an issue with the PHP 7.4 job:
Click on it and expand the Run PHPUnit 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:
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):
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:
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:
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:
Let's find out why. Click on the PHPUnit (7.4, lowest) job and expand the Run PHPUnit 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:
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.
Top comments (0)