Posted by Max Baldanza on Jan 19, 2023

Update PHP version from 7.4 to 8.1

Introduction

The majority of MyBuilder apps and services run on PHP. PHP7.4 end of life was November 2022, this meant that the version we were using had already stopped receiving bug fixes and would subsequently stop receiving security updates from November the 28th. This gave us a few months time to update all our applications so that we were not exposed to any known security vulnerabilities.

At the time of updating, PHP8.1 was the latest supported version (but of course PHP8.2 has now been released) so 8.1 is the version we chose to update to. I’ll go through some of my favourite features of PHP8, explain our process, issues we faced and how we did it. Hopefully it gives you the confidence to do the same.

Why update?

Firstly, PHP7.4 stopped receiving bug fixes at the beginning of January 2022 and more importantly it stopped receiving security fixes at the end of November 2022 which means we’d be exposed to any security vulnerabilities and any bugs. From a security point of this is a massive risk so the aim was to update all our applications before this deadline.

Secondly, new versions of PHP bring with it new features and performance improvements. As with previous versions, PHP8.0 and PHP8.1 brought with it lots of features but my personal favourites are the typed improvements, constructor property promotion, match expression, readonly properties and enums. Let’s go in to a little detail about some of these;

Typed improvements

In PHP7 return types and scalar types were added, this was a great change in my opinion but it was missing the ability to return multiple types (apart from returning null). PHP 8.0 introduced union types and the mixed type to make this a little easier.

Union types are particularly helpful in tests, as an example in PHP7.4 we’d have to use docblocks if an object was being mocked:

    /**
     * @var Job|MockObject
     */
    private $job;

    protected function setUp(): void
    {
        $this->job = $this->createMock(Job::class);
    }

In PHP 8 we can rewrite this to:

    private Job|MockObject $job;

    protected function setUp(): void
    {
        $this->job = $this->createMock(Job::class);
    }

Hopefully this will now mean the end of docblocks. Docblocks don’t offer any type checking and therefore can be misleading especially with older code that may have changes along the way.

Constructor property promotion

This reduces the amount of code a developer will need to write and therefore making our lives easier. As an example in PHP7.4 we’d have to write:

class Customer
{
    private string $name;
    private string $email;

    public function __construct(
        string $name, 
        string $email
    ) {
        $this->name = $name;
        $this->email = $email;
    }
}

In PHP 8 we can now write:

class Customer
{
    public function __construct(
        private string $name, 
        private string $email
    ) {}
}

We no longer need to define the properties separately and are defined directly from our constructor We’ve gone from 13 lines of code to 7 which means less writing but also means we can see more code on our screen without scrolling so improving the legibility of our class.

Match expression

This is very similar to a switch statement but reduces the amount of code we need to write. As an example in PHP7.4 we’d use a switch statement like so:

switch ($statusCode) {
    case 200:
    case 300:
        $message = 'Success';
        break;
    case 400:
        $message = 'Bad request';
        break;
    case 404:
        $message = 'Not found';
        break;
    case 500:
        $message = 'Server error';
        break;
    default:
        $message = 'Unknown status code';
        break;
}

Using match:

$message = match ($statusCode) {
    200, 300 => 'Success',
    400 => 'Bad request',
    404 => 'Not found',
    500 => 'Server error',
    default => 'Unknown status code',
};

Readonly properties

This allows us to set a property as readonly meaning that once they have been written they can no longer be modified. As an example:

class Customer
{
    public function __construct(
        public readonly string $name, 
        public readonly string $email
    ) {}
}

$customer = new Customer('Max', /* … */);

$customer->name = 'Other'; // Error: Cannot modify readonly property Customer::$name

This feature is useful for immutable DTO’s (Data Transfer Object) or VO’s (Value objects) to make sure once they are created they cannot be modified and avoids having to create getters. In PHP7.4 you’d make the above private and then likely have a getter but now we can make better use of public properties.

Enums

This allows us to represent a collection of constant values. For example if you have some statuses you could define them as so:

enum Status
{
    case DRAFT;
    case PUBLISHED;
}

You can then use these as types:

class BlogPost
{
    public function __construct(
        public Status $status, 
    ) {}
}

This means the Blog post will only allow a status value to be provided and if anything else is provided then it will throw an error.

What approach did we use?

Now that we’ve talked about the features let’s talk about how and what we did to update.

Before explaining our approach then it’s useful to understand a little about our architecture. In total, we have 10 Symfony apps all with various shared libraries and 6 of those applications are within our monorepo which is the oldest part of our system.

Planning

We first had a planning session to decide on how we were going to approach this and set up a document that we would use. As with most updates it’s very hard to know everything upfront so we wrote down what we did know, what we need to find out, the risks, what the contingency plan was, the approach, how we test and measure success and the rollback plan.

We decided the best approach was to update each one of our Symfony applications in multiple parts, this approach may not be the fastest but by splitting it up into multiple parts and smaller releases it reduced the risk. We also decided that updating our admin side first would be a good strategy as we’d gain faster feedback and test internally before rolling out with our customer facing changes. see any issues.

Using composer why-not php 8.1 gave us a very high level understanding of which external packages were not compatible with PHP8.1. The report from this gave us a good indication of dependencies we need to look at but didn’t necessarily tell us the full picture as some packages may have very lax requirements and have never been tested/used with the latest version of PHP even if their requirements say that they did.

What we did

We started looking at the outer levels of our applications by tackling internal repos that are used by all applications, for example we have a log bundle and a few other core bundles that are used throughout most of our applications. We use CI/CD on these with lots of automated tests so we were able to add PHP8.1 to our CI environment and view any problems with these repos and fix them fairly quickly. We then updated these packages so that they would run on both 7.4 and 8.1. This meant we could update these packages in the applications that used them and release them in preparation.

We then split up our apps and started tackling internal applications or small non critical applications first so we could minimise any customers being affected and to gain faster feedback.

We then started updating any external packages which is as simple as running composer update.

We then looked at packages that had not been updated in a while and if they would truly support the latest version of PHP or if the requirements were misleading.

As part of our CI steps we use Github actions to run for our CI/CD pipeline and to run our automated tests so at this point we could add PHP 8.1 into our CI step and run our automated tests, linting and static analysis tools against both 7.4 and 8.1 which allowed us to find any further issues. We could debate whether 100% code coverage is needed and at MyBuilder we believe it’s the quality of the tests that matter, we have good quality tests that cover all our critical paths so we can be confident if the tests pass then things should look pretty good.

We use a matrix strategy to test our packages so adding to CI was relativity easy:

Github actions

At this point it was time to update our infrastructure and as our applications run serverless using lambda and bref then this part was easy as it meant just updating the PHP layers we are using. We thought about how challenging this may be without serverless.

Before we pushed this to production we pushed this to our staging environment and ran some further manual checks and once all happy we could push this production.

We use Datadog as our monitoring tool with multiple dashboards, monitors and logs updated in realtime so we could quickly spot any issues and either quickly resolve them or put our rollback plan in to action if we needed to.

Challenges we faced

Unfortunately I’d love to say that everything went smoothly but with any PHP update then it can cause issues the biggest problem we had was updating one of our applications that uses SULU as to be able to update we had to update SULU version, PHP version and Symfony version at the same time due to composer requirements. When we deployed the changes they introduced a bug, due to the difference in paths between Symfony versions which gave the incorrect asset version. This resulted in the javascript, css and images path being incorrect and resulted in these assets not being available when loading the website and apps. We quickly rolled back, reviewed the issue and put mitigations in place to prevent this from happening in the future by throwing an exception as part of our build if we encountered an incorrect asset path.

We have clear boundaries between our applications which meant we could update the admin before we update the public facing site, doing so allowed us to find a PHP bug where datediff was not working as expected. A quick update of the Bref version we were using meant we could update PHP to 8.1.14 and voilà that resolved it.

We also had a few smaller issues with code that was no longer supported so if updating then this is certainly something to keep an eye on.

For instance, some built-in PHP functions switched to strictly typed arguments which meant that we did get a few TypeError for instance round() now only accepts an int|float so passing a string will cause a fatal error. It’s a simple fix as in most cases we could just cast to the correct type and luckily these issues were confined to our admin application.

In some of our older code we were also accessing array offset using curly brace syntax which now throws a fatal error again this was a simple fix by changing the curly braces to brackets $line{0}; to $line[0];. This was easy to spot and luckily for us found by our automated/manual tests.

What would we do differently?

Canary Deployments is something we talked about initially as it allows you to run both versions in parallel and slowly switch traffic to the updated version but our infrastructure didn’t support this and we wanted to update fairly quickly before 7.4 become EOL. Having Canary deployments in place would limit the risk further as you can slowly test with a small subset of users and gradually increase the set. If at any point a issue is found then you can quickly redirect traffic to the previous version.

What we learnt?

This approach relies on having a good test suite with multiple levels of tests. If you don’t have this then running static analysis tools and linters may find some of the issues, but would otherwise be tricky to have confidence in the update.

The easiest parts of our system to update were the smaller microservice type applications as they had fewer dependencies and were written outside the main monorepo where the code can be older and testing is much easier as they only dealt with a single task.

Next steps

We can now go back and remove PHP7.4 from our requirements and update to only support 8.1 with our applications and repos.

Our aim here was to make the minimum changes needed to update the codebase to support PHP8. The reason we decided to do that is to keep our PR’s as small as possible to review, to make it easier to revert (if needed) and to reduce some of the risk.

The next steps will be to use a tool like Rector to update our codebase to use PHP8 features such as Constructor property promotion, using match, the new str functions etc and use these going forwards and to then remove any deprecations.

Conclusion

Hopefully this showed that with a plan in place that a PHP upgrade doesn’t have to be as scary as it first may seem.

As with any big piece of work it’s best to slice this up in to multiple smaller parts that can be released independently and worked on independently, this not only reduced risk but means multiple team members can be involved so that it wouldn’t take as long.

If possible it’s always best to update any background process, admin interfaces before doing any public facing areas of the site. This allows you to spot any potential pitfalls that have missed and resolve them before a real customer would see the issue.

Now we can all look forward to PHP9 and all the new features that will bring 😀

Jobs at MyBuilder and Instapro

We need experienced software engineers who love their craft and want to share their hard-earned knowledge.

View vacancies
comments powered by Disqus