Composer best practices: dependencies, autoload and versions

Composer best practices: dependencies, autoload and versions

Managing dependencies in PHP became far simpler with Composer. The release of Composer 2.0 is a good excuse to share a few practices worth adopting.

What is Composer?

Composer is a dependency manager for PHP, in the same family as NPM or Yarn for JavaScript and pip for Python. It takes care of the packages your application depends on. As an extra service, Composer also lets packages register an autoloader. That combination makes it, to my mind, one of the most critical tools in the PHP ecosystem.

The package repository Composer uses most is Packagist.org, which only accepts open source packages. If you want everything Composer has to offer, there is also a private edition: a paid version of Packagist, built by the people behind Composer, which handles closed source packages with fine-grained access control.

I am not going to cover installing and configuring Composer here. The maintainers explain it in detail on the Introduction page of the Composer site. In every example that uses the Composer CLI, I assume Composer is installed globally and called with composer.

Splitting regular and development dependencies

Composer draws a line between two kinds of dependency: regular dependencies and development dependencies. Regular dependencies are the ones your code always needs, whatever environment it runs in. Development dependencies are only needed while the code is being worked on: test frameworks such as PHPUnit, static analysers such as Psalm, code style checkers such as PHP Code Sniffer. None of them are needed to run the code in production. A dependency added with composer require [package] is marked as a “regular dependency”. With the --dev flag, the package is marked as a “development dependency”.

Running composer install downloads and installs both the production and the development dependencies of your application. To download and install only the production ones, add the --no-dev flag. That is usually what you want in your deployment pipeline, since development dependencies have no business there.

NB composer install only downloads and installs the development dependencies of your own application or package, not those of its dependencies. So if you depend on “Package A” and “Package A” has a development dependency on “Package B”, only “Package A” lands in your project.

Development dependencies pile up into a respectable list, and therefore a fair number of files. All of them are reachable through the autoloader. Keeping the number of production dependencies down saves storage space, transfer time during deployment and execution time. At a small scale the storage and transfer savings are negligible, but as your application grows, or the number of people maintaining it does, they start to count. Another benefit of marking development dependencies is that they are left out of the Composer autoloader builds when you run composer install --no-dev.

Autoloading ( autoload.php )

As mentioned above, another Composer feature is the tooling that lets packages hook into your PHP project through autoloading ( autoload.php ). With it, a package describes how Composer should map a class name to a file name. You no longer have to fill your code with require_once and require calls. All you include is vendor/autoload.php.

As with dependencies, Composer offers two kinds of autoloader: regular autoload directives and development autoload directives. Packages use this to keep their footprint and their autoloader small, and applications benefit too. In a well tested project, the files used only by the test suite can be a sizeable share of the code base. The same goes for development utilities such as command-line tools: you do not need them in production. Telling the autoloader that these files belong to a development autoloader keeps them out of composer install --no-dev, which can improve the performance of your application. The split between regular and development autoload is made in the composer.json configuration file:

javascript
{
"autoload": {
"psr-4": {
"MyApp\": "src/"
}
},
"autoload-dev": {
"psr-4": {
"MyApp\Tests": "tests/"
}
}
}

Composer has further tooling to speed up autoloaded functions and classes, through autoloader optimisation.

Version constraints

Before Composer, a developer downloaded a specific version of a dependency and committed it to version control. With Composer, we can step away from a fixed version and let Composer work out exactly which version of a dependency to install. To do that, Composer leans heavily on semantic versioning. And it does so not only for your application: it extends the behaviour to the dependencies themselves. Packages can have their own dependencies, which can have their own, and so on. The result is more packages, but smaller ones. Those small packages can focus on a single feature, which keeps that feature in one place.

Compared with NPM, for instance, Composer allows only one version of a package per application. Fewer packages have to be installed, but there is an added risk of version collisions. That is a good reason not to pin a specific version of a dependency and to ask for a version range instead. This is usually done with the caret operator: ^1.0. It means every version >= 1.0.0 and < 2.0.0 is accepted. Semantic versioning being what it is, you are guaranteed the features you need and no backwards-incompatible change along the way.

By specifying a range rather than a fixed version, you avoid version collisions when another dependency uses the same package. Take this situation.

The composer.json of your application:

javascript
{
"require": {
"vendor/package": "^1.4",
"another_vendor/package": "^1.0"
}
}

The composer.json of vendor/package:

javascript
{
"require": {
"another_vendor/package": "^1.2"
}
}

When installing dependencies, Composer sees several requirements on another_vendor/package, and because they are constraints it can work out that it should avoid version 1.1.5 and install 1.2.8 instead.

Composer has several constraint operators and even lets you combine them. A constraint such as “php”: “^7.2” will not include PHP 8. To state that both PHP 7.2 and PHP 8 are supported, two operators can be combined: “php”: “^7.2 || ^8.0”.

NB Another option is the approach Symfony took, going for "php": >= 7.2". Personally I am not keen on it, because it claims the code will also work on PHP 9 and above. Since there is no way to know which backwards-incompatible changes PHP 9 will bring, I would rather not make that promise, which is why I settled on "php": "^7.2 || ^8.0" in my repositories.

To root or not to root

Once a composer.json has been read, a distinction can be drawn between the dependencies your project requires directly, at the root, and the rest. In Composer terminology, root packages are the dependencies at the root that pull in dependencies of their own.

Take this composer.json:

javascript
{
"require": {
"laminas/laminas-diactoros": "^2.0"
}
}

Running composer install on that file would show that not only laminas/laminas-diactoros is downloaded, but also laminas/laminas-zendframework-bridge, psr/http-factory and psr-http-message. In this example, laminas/laminas-diactoros is a root package that sits at the heart of our project.

The difference between a root package and a package pulled in below it is the number of direct dependencies in your project. As a rule, fewer root packages means fewer packages and fewer conflicts when updating them. Fewer packages, because a new version of a package can come with different dependencies, which Composer handles on its own. And fewer conflicts, because there are fewer constraints in your dependency tree.

So what makes a package a candidate for the root?

code
{
"require": {
"laminas/laminas-diactoros": "^2.0"
}
}

Look again at the example where we rely only on laminas/laminas-diactoros. Diactoros provides an implementation of the interfaces defined in psr-http-message and psr/http-factory. If my code only ever refers to objects from laminas/laminas-diactoros, that is a valid Composer setup. But if I refer to the interfaces defined in psr-http-message and psr/http-factory, which I consider good practice, then that is a reason to make psr-http-message and psr/http-factory root packages of our project.

js
{
"require": {
"laminas/laminas-diactoros": "^2.0",
"psr-http-message": "^1.0",
"psr/http-factory": "^1.0"
}
}

Specifying your environment

You build your application or your package against a given version of PHP, and that is the only version you can guarantee your code on. Your code may also need a PHP extension to be installed. That is why it is good practice to state which PHP version or versions are required or supported, and which extensions are needed.

When building a package, PHP versions and PHP extensions can be used as though they were ordinary packages:

javascript
{
"require": {
"php": "^7.2",
"ext-curl": "*"
}
}

The reason we can use the * wildcard for the cURL extension is that the extension is tied to PHP, so there is no version to specify.

You can take the same approach in an application, and on top of that declare those restrictions as platform configuration:

javascript
{
"config": {
"platform": {
"php": "7.4.4",
"ext-curl": "*"
}
}
}

The benefit is that Composer treats PHP 7.4.4 as the PHP version, even if a different one is installed on the machine running the Composer commands. With virtualisation solutions such as Docker and Vagrant now the norm, this lets people run Composer commands locally while the application runs in the virtualised environment. It also stops a colleague on a bleeding-edge PHP version from installing dependency versions the production environment cannot support.

Working with source control ( Git, Mercurial, Subversion )

In your project, Composer leaves three things on your file system: two files called composer.json and composer.lock, and a folder called vendor*. All three do not have to go into version control.

composer.json lists every package that is required, along with its version constraints, see the “Version constraints” section for more on that. This file must always be committed. Without it, anyone who wants to use your code has no idea which packages are needed.

composer.lock holds the exact versions, checksums included, of every package. If the file is present when you run composer install, exactly the versions listed in the composer.lock are installed. That is how you make sure the dependencies are the same in every environment. When building an application, commit it. The main reason is that applications (or other packages) using your package will ignore that file, while your local development environment will not. You can end up developing and testing against outdated versions of your dependencies while the users of your package are on the latest ones.

The vendor folder holds the code of the packages. Its contents should not be committed. If you use Git, add it to a .gitignore so it stays out of version control.

* You can change the name and the location of that folder with the vendor-dir config option, but for this article I have stuck to the default.

Changing dependencies

Say you want to update one dependency in your application. Or several. You run a handful of composer update commands and watch composer.lock change. You make a commit. Another batch of updates, another commit. A few days later you pick up where you left off. The repository is a busy one, so before carrying on you rebase to make sure your changes sit on top of the latest code. Merge conflict. A conflict in composer.json is resolved like any other, but a conflict in composer.lock is harder.

The way to resolve a merge conflict in a composer.lock is to accept the incoming changes, the most recent ones, and run again the Composer commands that produced your changes in the first place. That way your changes end up applied to the latest version of your code. Commit the new composer.lock and carry on with the rebase. If a later commit also touched Composer, you go through the same steps again.

Conclusion

Composer has become an essential part of PHP development. Like any package manager, it leaves room for optimisation. I use Composer daily across several repositories, and over that time a handful of habits have proved worth keeping:

  • Split your dependencies into production and development dependencies to keep the application small.
  • Split your autoloader into a production and a development autoloader to keep the autoloader small and performance up.
  • Do not depend on a fixed version of a dependency but on a range, to make upgrading packages less painful.
  • Depend directly only on the packages you actually call in your application, so that upgrading or switching to an alternative stays simple.
  • Always put composer.json under source control, but only put composer.lock under source control for applications, so that users of your package are not stopped from updating other packages.
  • Try to keep changes to composer.json and composer.lock to a single commit per pull request or merge request, to minimise merge conflicts in those files.

PHP

Damien Flandrin Web developer since 2010, creator of Gekkode and Email Impact. Every article is tested on a real project before publication. Contact
Newsletter

New tests, tutorials and projects, by e-mail.

Reproducible tests, versioned code, dated results. Never any spam.