Managing your dependencies in PHP

Théo Fidry
8 min readDec 2, 2017

--

When you are creating a PHP application or library, you usually have 3 kinds of dependencies:

  • Hard dependencies: what your application/library needs to be able to run
  • Optional dependencies: for example a PHP library can provide a bridge for different frameworks
  • Development dependencies: debugging tools, test frameworks…

How to manage those dependencies?

source: https://getcomposer.org/

Hard dependencies:

{
"require": {
"acme/foo": "^1.0"
}
}

Optional dependencies:

{
"suggest": {
"monolog/monolog": "Advanced logging library",
"ext-xml": "Required to support XML"
}
}

Optional & Development dependencies:

{
"require-dev": {
"monolog/monolog": "^1.0",
"phpunit/phpunit": "^6.0"
}
}

So far so good. So what can go wrong? Basically require-dev which has some limitations.

Issues & limitations

Too many dependencies

Dependencies with a package manager are great. It’s a fantastic mechanism to re-use existing code and being able to update it easily. You should however be responsible about which and how many dependencies you are including. You are still including code and as such it can be buggy or insecure. You are becoming depend on something someone else has written and on which you may have no control over besides becoming subject to third-party problems. Packagist and GitHub are among others doing a fantastic job at reducing some of those risks, but the risk still exists nonetheless. What happen with the left-pad fiasco in the JavaScript community is a good example that things can go wrong and adding a package is not without consequences.

A second issue with dependencies is that they need to be compatible. That’s Composer job. But as great as Composer is, there is some dependencies you cannot install together and the more dependencies you add the more likely you are to encounter a conflict.

TL:DR; Be responsible about which dependencies you are including and strive for few dependencies.

Hard conflict

Let’s consider the following example:

{
"require-dev": {
"phpstan/phpstan": "^1.0@dev",
"phpmetrics/phpmetrics": "^2.0@dev"
}
}

Those two packages are static analysis tools and they might not be installable together, i.e. create a conflict because they might depend on different and incompatible versions of PHP-Parser.

This is a case of “silly conflict: a conflict should occur only if you are trying to include a dependency which is incompatible with your application. Those two packages don’t need to be compatible, your application are not using them directly and they are not executing your code application either.

Another example in the case of a library for which you provide a Symfony and Laravel bridge. You may want to include both Symfony and Laravel as dependencies to test those bridges:

{
"require-dev": {
"symfony/framework-bundle": "^4.0",
"laravel/framework": "~5.5.0" # gentle reminder that Laravel
# packages are not semver
}
}

This might work fine in some cases but very likely break most of the time. This is a bit silly here as well as you are very unlikely to have a user requiring both of those packages at the same time and you are even more less likely to want to support this scenario.

Untestable dependencies

If you look at the following composer.json:

{
"require": {
"symfony/yaml": "^2.8 || ^3.0"
},
"require-dev": {
"symfony/yaml": "^3.0"
}
}

There is something happening there… The only installable versions of the Symfony YAML component (the symfony/yaml package will be [3.0.0, 4.0.0[.

In an application, you most likely do not care. In the case of a library however this might be an issue. Indeed this means you will never be able to test your library for symfony/yaml [2.8.0, 3.0.0[.

Whether or not this is indeed an issue depends a lot on your case. The thing here is to be aware that this can happen and that there is no trivial way to identify it. The case above is simple, but if that symfony/yaml: ^3.0 is a requirement is hidden deeper in the dependency tree, for example:

{
"require": {
"symfony/yaml": "^2.8 || ^3.0"
},
"require-dev": {
"acme/foo": "^1.0" # requires symfony/yaml ^3.0
}
}

You will have no way, for now at least, to know about it.

Solutions

Do not use the package

KISS. It’s ok, you don’t really need that package after all!

PHARs

PHARs (PHP Archives) are a way to package an application into a single file. If you want to know more about it I recommend you the official PHP documentation for it.

Example of usage with PhpMetrics, a static analysis tool:

$ wget https://url/to/download/phpmetrics/phar-file -o phpmetrics.phar$ chmod +x phpmetrics.phar
$ mv phpmetrics.phar /usr/local/bin/phpmetrics
$ phpmetrics --version
PhpMetrics, version 1.9.0
# or if you want to keep the PHAR close and do not mind the .phar
# extension:
$ phpmetrics.phar --version
PhpMetrics, version 1.9.0

Warning: bundling code into a PHAR does not isolate it unlike, say, JARs in Java. That said, there is PHP-Scoper which is being developed to tackle that issue.

Let’s illustrate that issue with an example. You built a console application myapp.phar which relies on Symfony YAML 2.8.0 which execute a given PHP script:

$ myapp.phar myscript.php

Your script myscript.php is leveraging Composer to use Symfony YAML 4.0.0.

What may happen is the PHAR loads a Symfony YAML class, e.g. Symfony\Yaml\Yaml and then execute your script. Your script also uses Symfony\Yaml\Yaml, but guess what, this class is already loaded! The issue there is that the one loaded is from the symfony/yaml 2.8.0 package, not the 4.0.0 that your script needs. As a result, if the API differs, this is gonna break hard.

TL:DR; PHARs are great for static analysis tools like PhpStan or PhpMetrics but are unreliable (for now at least) as soon as they execute code because of those dependency collisions (for now least!).

There is also a few other things to keep in mind when using PHARs:

  • They are harder to track as there is no native support for them in Composer. There is however a few solutions like the Composer plugin tooly-composer-script or PhiVe, a PHAR installer.
  • How the versions are managed depends a lot on the project. Some projects offers a self-update command à la Composer with different channels of stability, some provide a unique download endpoint with the latest release, some make use of the GitHub release feature and ship a PHAR for each release, etc.

Using multiple repositories

By far one of the most popular technique. So instead of requiring all the bridges dependencies in a single composer.json, we break down the package in multiple repositories.

If we take the previous example of the library which we’ll call acme/foo, then we will create another package acme/foo-bundle for Symfony and acme/foo-provider for Laravel.

Note that everything can actually still be in a single repository and have read-only repositories for the other packages like Symfony is doing.

The main advantage of that approach is that it remains relatively simple and do not require any extra tooling except ventually a repository splitter like splitsh which is used for example for Symfony, Laravel and PhpBB. The drawback is that you now have multiple packages to maintain instead of one.

Tweak the configuration

Another way would be to have a more advanced installation & testing script. For the previous example, we could have something along the lines of:

#!/usr/bin/env bash
# bin/tests.sh
# Test the core library
vendor/bin/phpunit --exclude-group=laravel,symfony
# Test the Symfony bridge
composer require symfony/framework-bundle:^4.0
vendor/bin/phpunit --group=symfony
composer remove symfony/framework-bundle
# Test the Laravel bridge
composer require laravel/framework:~5.5.0
vendor/bin/phpunit --group=symfony
composer remove laravel/framework

It works, but in my experience at least, this leads to bloated test scripts which are relatively slow, hard to maintain and not being very friendly for new contributors.

Use multiple composer.json

This approach is relatively new (in PHP) mostly because the required tooling was not there, so I’ll expand a bit more on that solution.

The idea is relatively simple. Instead of having:

{
"autoload": {...},
"autoload-dev": {...},
"require": {...},
"require-dev": {
"phpunit/phpunit": "^6.0",
"phpstan/phpstan": "^1.0@dev",
"phpmetrics/phpmetrics": "^2.0@dev"
}
}

We will install phpstan/phpstan and phpmetrics/phpmetrics in different composer.json files. But here comes the first issue: where do we put them? Which structure to adopt?

This is where composer-bin-plugin comes in. It’s a very simple Composer plugin that allows you to interact with a composer.json based in a different directory. So lets say we have our root composer.json file:

{
"autoload": {...},
"autoload-dev": {...},
"require": {...},
"require-dev": {
"phpunit/phpunit": "^6.0"
}
}

We can install the plugin:

$ composer require --dev bamarni/composer-bin-plugin

Now that the plugin is installed, whenever you do composer bin acme smth, it will execute the command composer smth in the sub-directory vendor-bin/acme. So we can now install PhpStan and PhpMetrics like so:

$ composer bin phpstan require phpstan/phpstan:^1.0@dev
$ composer bin phpmetrics require phpmetrics/phpmetrics:^2.0@dev

This will create following directory structure:

... # projects files/directories
composer.json
composer.lock
vendor/
vendor-bin/
phpstan/
composer.json
composer.lock
vendor/
phpmetrics/
composer.json
composer.lock
vendor/

Where vendor-bin/phpstan/composer.json looks like this:

{
"require": {
"phpstan/phpstan": "^1.0"
}
}

And vendor-bin/phpmetrics/composer.json looks like this:

{
"require": {
"phpmetrics/phpmetrics": "^2.0"
}
}

So now we can easily use PhpStan and PhpMetrics by calling vendor-bin/phpstan/vendor/bin/phpstan and vendor-bin/phpmetrics/vendor/bin/phpstan.

Let’s now go further and take the example of a library with bridges for different frameworks:

{
"autoload": {...},
"autoload-dev": {...},
"require": {...},
"require-dev": {
"phpunit/phpunit": "^6.0",
"symfony/framework-bundle": "^4.0",
"laravel/framework": "~5.5.0"
}
}

So we apply the same approach and we end up with avendor-bin/symfony/composer.json file for the Symfony bridge:

{
"autoload": {...},
"autoload-dev": {...},
"require": {...},
"require-dev": {
"phpunit/phpunit": "^6.0",
"symfony/framework-bundle": "^4.0"
}
}

And another vendor-bin/laravel/composer.json for the Laravel bridge:

{
"autoload": {...},
"autoload-dev": {...},
"require": {...},
"require-dev": {
"phpunit/phpunit": "^6.0",
"laravel/framework": "~5.5.0"
}
}

Our root composer.json would now look like this:

{
"autoload": {...},
"autoload-dev": {...},
"require": {...},
"require-dev": {
"bamarni/composer-bin-plugin": "^1.0"
"phpunit/phpunit": "^6.0"
}
}

To test the core library and the bridges you would now create 3 different PHPUnit files, one for each with the proper autoload file (e.g. vendor-bin/symfony/vendor/autoload.php for the Symfony bridge).

If you actually try this, you will notice a major flaw in this approach: the redundant configuration. Indeed you would need to duplicate the config of the root composer.json into the other two vendor-bin/{symfony,laravel/composer.json, adjust the autoload sections as the path to the files would have changed and whenever you require a new dependency, you would need to require it in the other composer.json files as well. This is not manageable, this is where composer-inheritance-plugin comes in.

This plugin is tiny wrapper around composer-merge-plugin to merge the content of vendor-bin/symfony/composer.json with the root composer.json. So instead of having:

{
"autoload": {...},
"autoload-dev": {...},
"require": {...},
"require-dev": {
"phpunit/phpunit": "^6.0",
"symfony/framework-bundle": "^4.0"
}
}

You will now have:

{
"require-dev": {
"symfony/framework-bundle": "^4.0",
"theofidry/composer-inheritance-plugin": "^1.0"
}
}

The rest of the config, autoload and dependencies of the root composer.json will be included. There is nothing to configure, composer-inheritance-plugin is just a thin wrapper around composer-merge-plugin to preconfigure everything for this usage with composer-bin-plugin.

If you want to, you could inspect the dependencies installed for it with:

$ composer bin symfony show

I have been using this approach in several projects like alice for different tools like PhpStan or PHP-CS-Fixer and framework bridges. Another example would be alice-data-fixtures for which there is a lot of different ORM bridges for the persistence layer (Doctrine ORM, Doctrine ODM, Eloquent ORM, etc.) and framework integrations.

I also use it in multiple private projects for applications for different tools as an alternative to PHARs and it has been working very well.

Conclusion

I am sure some will find some approaches weird or not recommend to use them. The goal here is not to judge or recommend one in peculiar, but rather lay out a list of possible ways to manage some dependencies and what are the benefits and drawbacks of each. So depending of the issue you have and your personal preferences, pick the one that works the best for you. As one said, there are no solutions, only tradeoffs.

Edit: Added a link to PHP-Scoper roadmap when mentioning the lack of isolation of the code bundled in the PHARs.

--

--