Versioning a PHP API with Composer

This year I’ve had the opportunity to learn and grow in many different ways. Recently I’ve had reason to dive into the benefits and trade-offs that come with a formal versioning convention. As part of this I’ve been spending time returning to the fundamentals of versioning. One thing I always find useful is revisiting fundamentals with new insights, and so thought this would be a fitting topic to contribute to this blog.

I find the topic of versioning can often get lost in the abstract, which can make it tricky to engage with. Personally, after starting my career in the WordPress ecosystem and later transitioning over to Laravel, it’s a topic that I’ve cobbled together an understanding of over years of partial explanations and practical uses. Therefore I’ve aimed to draw out the specifics, and understand how they apply to the more abstract concepts. I find working with package versions in PHP quite seamless thanks to the Composer dependency manager, and I feel that it makes quite a good use case for walking through how to version an API.

Introduction (1.0.0)

In this post we’ll walk through how to version a PHP API by using Composer. It presumes some general familiarity with the PHP language (7.0+), the Composer dependency manager, and Git. This post will cover what versioning an API means, what a PHP API could look like, the basics of contemporary versioning practices, and how to manage this all via Git and Composer.

Definitions

First, let’s put some preliminary definitions on the table:

TermDefinition
APIAn Appliction Programming Interface. You can think of an API as the outward facing blueprint of how to interact with your application. An intuitive example of an API is a HTTP REST API, but an API could also be derived from a library, a CLI, or any other public interface that you want to make commitments for.
VersioningThe process of specifying meaningful milestones in your application. Versioning allows you to differentiate between different iterations of your codebase. A version has a name associated with it, which can be anything: a text label (i.e Mac OS X Snow Leapard, El Capitan, etc), a year, a number, a Semantic Version, etc.
PackagistThe main public registry for Composer packages at packagist.org. When you run composer install it’s likely that your package installs and updates are managed via the Packagist registry.

For the purposes of this post, we’ll look exclusively at the Semantic Versioning (“semver”) convention. You may be familiar with the format of a semver version name; the name is comprised of three numbers separated by dots, such as 2.1.5. The first number represents a major version, the second represents a minor version, and the third represents a patch version. We’ll cover what these mean later.

An example API

To demonstrate how to version a PHP API, we’ll use an example of a library that determines the Northern Hemisphere’s season of a given month. For example, if you give input of the number 12 (representing the month of December), it should return "WINTER". Let’s look at our example library below:

<?php

namespace YourUsername\WeatherLibrary;

// src/Weather.php

class SeasonException extends \Exception {}

interface WeatherInterface {
  /**
  *    @param  int  $month  An integer in the range of 1, 2, ..., 11, 12
  *    @return  string  A string of "WINTER", "SPRING", "SUMMER", or "AUTUMN"
  *    @throws  SeasonException
  */
  public static function getSeason(int $month): string;
}

class Weather implements WeatherInterface {
  /**
  *    {@inheritDoc}
  */
  public static function getSeason(int $month): string
  {
    if ($month < 1) {
      throw new SeasonException("The given month must be greater than or equal to 1.");
    }

    if (in_array($month, [12, 1, 2])) {
      return "WINTER";
    } else if (in_array($month, [2, 3, 4])) {
      return "SPRING";
    } else if (in_array($month, [5, 6, 7])) {
      return "SUMMER";
    } else {
      return "AUTUMN";
    }
  }
}

As you can see, our example is not a HTTP REST API. You can imagine that our example is a part of a library that can be added to a codebase to provide data regarding the weather. As mentioned above, an API is not exclusively a HTTP REST API; it can be any kind of public interface that you want to make commitments for. In our case our API includes the library method getSeason.

To help make our example library easier to understand, you can also find my implementation on GitHub. You might find it helpful to use this as a resource to follow along with. It’s not required to actually follow along with creating the package, but you may find it useful to help cement some of the concepts (nothing beats working through the problem firsthand).

Let’s presume that the snippet above meets our initial needs, and we’re ready to release our initial version of the library. The library’s directory structure currently looks like this:

- weather-library/
  - src/
      - Weather.php

Tagging with Git

Before we commit our changes, let’s set up the Composer file. First, you will need to create an account at packagist.org. Next let’s create a composer.json file in our project by running: composer init --type library. The chosen package name should be {your-username}/weather-library, where {your-username} is the username for your Packagist account. It will also prompt you for some other details, which you can choose as you prefer.

Now that you’ve created the Composer file, you should see that it has set up some autoloading configuration. Be sure to return to your Weather.php file, and replace the YourUsername part of the namespace to match your actual username used in the autoload map. For example, mine is namespace ElliotMassen\WeatherLibrary.

In order to tag our package, we’ll need a Git repository. I’ll be providing the Git CLI commands to set up the repository, but you may also follow along using their equivalents in any Git-compatible GUI application (such as PhpStorm, GitHub Desktop, etc). You can create a local Git repository with git init, and then stage and commit the changes with git add . & git commit -m "Initial commit". As our library is ready to be used by others, and we want a permanent reference to this specific version of the library, we’ll tag our first version of 1.0.0. To create the tag, run git tag -m "1.0.0" 1.0.0.

Now that the local repository is created, and the initial version is tagged, we’re now ready to create the remote repository, which is required in order to publish our package to the package registry. In this post we’ll use GitHub for this. You can either create a repository on github.com or use the GitHub CLI (gh repo create). You will need to ensure that the repository is public, as otherwise we will not be able to publish it to Packagist. Once the remote repository is created, you’ll need to specify it as a remote for your local repository via git remote add origin https://your-remote-repository-here.git. Now let’s push the initial commit to the remote with git push --follow-tags -u origin HEAD, this will push the initial commit and tag to the main branch in the GitHub repository.

At this point if you run git log you should see something like the following:

commit 3d983033baefd8a9ef70f46ce033fbffbe5f150b (HEAD -> main, tag: 1.0.0, origin/main)
Author: Elliot Massen <contact@elliotmassen.com>
Date:   Thu Dec 8 20:03:49 2022 +0000

    Initial commit

Registering a Composer package

Now that we’ve created a public repository, it’s time to submit it to the Packagist registry. This will mean that we (or anyone else) can install the package in an application, and most importantly that we can specify which version of the package to install. In order to submit the package, you’ll need to login to your account at packagist.org. You can then submit your package. If you would like Packagist to automatically detect new tags on your repository, you’ll need to ensure that it has appropriate access to your GitHub account via a GitHub hook. If you would prefer not to do this, you can trigger a manual update on Packagist after you push each tag. After the package is submitted, we’re ready to install it in a project.

Using the example package

The reason that you would publish a package is so that it can be used by another project (and as it’s a public package, it may even be used by other people). Let’s look at how that works by creating a new project that uses the weather library.

In this example project, we’ll only cater for the code to be run via the PHP CLI, and it will simply echo the season of the current month. However with more time (and a longer blog post!) you could create a web application that makes use of the package in other ways.

To set up the example project, create a new directory (outside of the previous one used to create the library), and run composer init && composer install. This project won’t be submitted to Packagist (or be pushed to GitHub) so you can name it however you like. Within your directory, create a new file called index.php and paste the following in:

<?php

// index.php

require "vendor/autoload.php";

$currentMonth = date('n');

try {
    echo "The current season is: " . \YourUsername\WeatherLibrary\Weather::getSeason($currentMonth);
} catch (\YourUsername\WeatherLibrary\SeasonException $e) {
  echo "ERROR: " . $e->getMessage();
}

This code gets the current month, and uses it as an input to our getSeason method. As good practice, we are also catching the documented potential SeasonException.

Let’s run the project with php -f index.php. And… oops, it seems like there’s been an error:

Warning: Uncaught Error: Class "YourUsername\WeatherLibrary\Weather" not found in php shell code:1

Our Weather class isn’t available to our code, because we haven’t yet installed the package. So, let’s resolve that! Run composer require {your-username}/weather-library. This will consult the Packagist registry, and install your package. It will also update the composer.json & composer.lock files to add the dependency, and store the specific version in use. After that’s finished installing, make sure to update both occurances of YourUsername in index.php to match your namespace, and run php -f index.php again. This time it should work, and the output should be:

"The current season is: WINTER" // Or whatever the relevant season is in the month you are reading this post

If you open your composer.json file, you should now see that your package is listed in the require object. The key will be the package name, and the value will be the version constraint. The current constraint is ^1.0 which means that when running composer update the latest version of the package will be installed, so long as it is greater than or equal to 1.0.0 and less than 2.0.0. This will be important in the next sections.

Now that the package has been created, and used in a project, we’ll cover how to handle changes to the package. In the following sections we will investigate all three of the different semver version types: patch, minor, and finally, major.

Patch version (1.0.1)

A patch version is a version in which a backwards compatible change is made that doesn’t introduce new functionality, for example a bug fix. Usually this is when some part of the existing functionality behaves in a way that is different from what was expected.

Let’s return to the example library to make a patch version. Currently the getSeason method accepts a $month parameter of any integer than is greater than 0, otherwise it throws an exception. However this did not account for integers that are greater than 12! Without making a fix, it means that if the function receives the number 100 it will return "AUTUMN" which isn’t correct, and could cause a nasty bug in someone’s codebase. Let’s fix this by introducing another if statement to our getSeason method:

public static function getSeason(int $month): string
{
  if ($month < 1) {
    throw new SeasonException("The given month must be greater than or equal to 1.");
  }

  if ($month > 12) {
    throw new SeasonException("The given month must be less than or equal to 12.");
  }

  if (in_array($month, [12, 1, 2])) {
    return "WINTER";
  } else if (in_array($month, [2, 3, 4])) {
    return "SPRING";
  } else if (in_array($month, [5, 6, 7])) {
    return "SUMMER";
  } else {
    return "AUTUMN";
  }
}

With this fix in place, we’re now ready to release our patch version. After committing the change, run git tag -m "1.0.1" 1.0.1 to tag the new version. Push the commit and tag to the remote repository as you did previously, and then we can proceed to update the dependency in the application.

In the example application run composer update to update the library to the 1.0.1 version. As before, this will consult Packagist and fetch the most recent version of the package that meets the version constraint. After it’s updated you should still be able to run your application with php -f index.php. You should also now be able to test the fix with the following change (as before you will need to update occurances of YourUsername):

<?php

// index.php

require "vendor/autoload.php";

$currentMonth = 100;

try {
    echo "The current season is: " . \YourUsername\WeatherLibrary\Weather::getSeason($currentMonth);
} catch (\YourUsername\WeatherLibrary\SeasonException $e) {
  echo "ERROR: " . $e->getMessage();
}

When you run this, you should now see the error message: ERROR: The given month must be less than or equal to 12.. If we had run this with version 1.0.0 of the library then we would have not got the expected error message, and instead would have incorrectly seen "The current season is: AUTUMN".

Notice how in our change to the library method we haven’t changed the method signature; it still remains a static method that takes a single integer parameter, returns a string, and potentially throws a SeasonException. We should expect that any codebase that has installed our library has factored this signature into it’s use of the method (for example, the method call should be wrapped in a try-catch, or have documented that the containing block throws the exception). The consumer should expect that they can keep pulling patch versions for their current version (eg. first 1.0.0, then 1.0.1, then 1.0.2, etc) and retain the exact same API. However this is limiting if we want to move beyond fixes, and add completely new functionality to the API…

Minor version (1.1.0)

A minor version is a version in which a backwards compatible change is made that introduces new functionality, for example a new endpoint, function or method. Usually this is necessary when you are releasing a new feature to your package.

Let’s see what that would look like by updating the Weather class in the weather library.

class Weather implements WeatherInterface {
  public const WINTER = 'WINTER';
  public const SPRING = 'SPRING';
  public const SUMMER = 'SUMMER';
  public const AUTUMN = 'AUTUMN';

  /**
  *    {@inheritDoc}
  */
    public static function getSeason(int $month): string
  {
    if ($month < 1) {
      throw new SeasonException("The given month must be greater than or equal to 1.");
    }

    if ($month > 12) {
      throw new SeasonException("The given month must be less than or equal to 12.");
    }

    if (in_array($month, [12, 1, 2])) {
      return self::WINTER;
    } else if (in_array($month, [2, 3, 4])) {
      return self::SPRING;
    } else if (in_array($month, [5, 6, 7])) {
      return self::SUMMER;
    } else {
      return self::AUTUMN;
    }
  }
}

In this change we have expanded the library’s API by introducing four new public constants, with one constant for each season. As these are new aspects of the API that we’ll be making commitments to, this qualifies these changes under a minor version. Although this does also make some changes within the getSeason method, it has not changed anything about the method’s signature, nor the values that are returned. All that has changed within the method is how we store those values, which (by itself) isn’t of consequence to the consuming application. The thing that makes this a minor version is the fact that the constants we’ve introduced have public visibility, and that we are inviting the consuming application to use them.

Following the process outlined in previous steps: commit that change, tag it as 1.1.0 and push them to the remote repository.

Let’s update the dependency in the example application again via composer update. Now let’s make use of the new constants (please forgive me for these contrived examples… I’m really reaching now).

<?php

// index.php

require "vendor/autoload.php";

use \YourUsername\WeatherLibrary\Weather;
use \YourUsername\WeatherLibrary\SeasonException;

$currentMonth = date('n');

try {
    $season = Weather::getSeason($currentMonth);

  if ($season === Weather::WINTER) {
    echo "It's winter";
  } else {  
        echo "The current season is: " . $season;
  }
} catch (SeasonException $e) {
  echo "ERROR: " . $e->getMessage();
}

When you run this, in a winter month, you should now the new "It's winter", and, if not in a winter month, then the same message as before. If we had run this with version 1.0.0 or 1.0.1 we wouldn’t have had access to the Weather::WINTER constant, and would have received an error. This is why this counts as a minor version, and not a patch, as there are new aspects our API.

Notice how in our change to the library method we (again) haven’t changed the method signature; it still remains a static method that takes a single integer parameter, returns a string, and potentially throws a SeasonException. However we have introduced some new aspects. The consumer should expect that they can keep pulling minor versions for their current version (eg. first 1.1.0, then 1.2.0, then 1.3.0, etc) and that pre-existing aspects of the API will remain the same. However this is limiting if we want to change those pre-existing aspects…

Major version (2.0.0)

A major version is a version in which a backwards incompatible change is made. This change can break the existing API, for example by refactoring or removing functionality.

Let’s see what that would look like in the example library (make sure to update YourUsername in the namespace):

<?php

namespace YourUsername\WeatherLibrary;

// src/Weather.php

class SeasonException extends \Exception {}

interface WeatherInterface {
  /**
  *    @return  string  A string of "WINTER", "SPRING", "SUMMER", or "AUTUMN"
  */
  public function getSeason(): string;

  /**
  *    @return  bool  Whether the month is in winter.
  */
  public function isWinter(): bool;
}

class Weather implements WeatherInterface {
  public const WINTER = 'WINTER';
  public const SPRING = 'SPRING';
  public const SUMMER = 'SUMMER';
  public const AUTUMN = 'AUTUMN';

  protected int $month;
  protected string $season;

  /**
  * @throws  SeasonException
  */
  public function __construct(int $month)
  { 
    if ($month < 1) {
      throw new SeasonException("The given month must be greater than or equal to 1.");
    }

    if ($month > 12) {
      throw new SeasonException("The given month must be less than or equal to 12.");
    }

    $this->month = $month;
    $this->season = $this->determineSeason();
  }

    protected function determineSeason(): string
  { 
    if (in_array($this->month, [12, 1, 2])) {
      return self::WINTER;
    } else if (in_array($this->month, [2, 3, 4])) {
      return self::SPRING;
    } else if (in_array($this->month, [5, 6, 7])) {
      return self::SUMMER;
    } else {
      return self::AUTUMN;
    }
  }

  /**
  *    {@inheritDoc}
  */
  public function getSeason(): string
  {
    return $this->season;
  }

  /**
  *    {@inheritDoc}
  */
  public function isWinter(): bool
  {
    return $this->season === self::WINTER;
  }
}

Although we’ve made a few changes to our library, the incompatible change is the one to the method signature for getSeason:

/**
      @returns  string  A string of "WINTER", "SPRING", "SUMMER", or "AUTUMN"
  */
  public function getSeason(): string;

Previously the method was static, took a single integer parameter, and threw a SeasonException, but now it is no longer static, takes no parameters, and throws no exception. You can easily imagine how this change would break an application if they updated to this version without also changing their integration.

As with the previous versions, let’s commit, tag and push this with the version of 2.0.0. If we now return to the example application and run composer update you will find that the application still works as it previously did. Why is this? The answer lies in the composer.json file. The version constraint that was added at the start (^1.0) means it will install any version above and including 1.0.0 but less than 2.0.0 . The constraint has protected the application from breaking when the new major version, 2.0.0, was released, which shows the strength of understanding and setting these constraints appropriately.

At this point it’s worth pausing to consider the reasons why you might want to update to the latest major version of a package that are beyond pure functionality. If the current version works for you, why not continue using it? This is where the reality of maintaining a package comes in (which you can find a plethora of good resources about elsewhere). If it the package is continuously maintained, perhaps the maintainers have documented some kind of “end-of-life” process for each major and/or minor version, like the PHP language itself, or the Laravel framework. In a worst case scenario, the package might not even be maintained indefinitely; perhaps the maintainers are not paid for their work, or the package has been abandoned. It is important to have an awareness of these realities as it can impact your ability (as a consumer of the dependency) to receive bug fixes and security support, which can be crucial for the usage of an application, and the protection of user data.

Presuming that we are interested in getting the latest API of the weather library, let’s give it a go and see what updates we’ll need to make to support 2.0.0 of our fictional package. Run composer require {your-username}/weather-library:^2.0 to update the version constraint and install 2.0.0 (as mentioned before composer update alone will not be sufficient this time).

Firstly, let’s not make any code changes to our integration with the package. Run the application, and you’ll see the following error:

Fatal error:  Uncaught Error: Non-static method {YourUsername}\WeatherLibrary\Weather::getSeason() cannot be called statically

This gives us an indication of what’s changed about the API. So, let’s fix this:

<?php

// index.php

require "vendor/autoload.php";

use \YourUsername\WeatherLibrary\Weather;
use \YourUsername\WeatherLibrary\SeasonException;

$currentMonth = date('n');

try {
  $weather = new Weather($currentMonth);
} catch (SeasonException $e) {
  echo "ERROR: " . $e->getMessage();
  return;
}

if ($weather->isWinter()) {
  echo "It's winter";
} else {  
  echo "The current season is: " . $weather->getSeason();
}

We’ve now updated our application to support the latest major version of our package. Our application still performs the same functionality, but uses the new API provided by our package to do so. You can also see that the application has become a little nicer to read too, which is an added benefit. For a well supported package you might expect some kind of upgrade guide to help you with this process.

Conclusion

After all that, we’ve worked through releasing a patch, minor, and a major version of a PHP package by making use of Git and Composer. All of this was in accordance with the semver versioning convention, which is used by many contemporary applications and packages.

If you’re interested to get a wider sense of this topic, you can find a similar systems in other languages, such as Javascript and NPM, or Rust and Cargo. There are also other versioning conventions other than Semantic Versioning, such as Calender Versioning.

I hope that this helps to enable you to clearly define and document an API in PHP, so that consumers of your API’s can manage their expectations about the severity of a change to it. At the very least you’ll always be able to know whether a month is in winter or not!!