← All resources

/// Article

We Upgraded Laravel 4.2 to Laravel 9. The Diff Deleted More Code Than It Added.

/// Based on the talk

Elevating Legacy

PHP UK 2024. The video goes through the same ideas in more detail.

Watch the talk →

This article is about an upgrade of a real production application from Laravel 4.2, released in 2014, to Laravel 9. Five major versions of the framework, and a big jump in PHP version to go with it.

If you've ever worked on a codebase like that, you'll know the feeling. Every new library you want to use needs a newer PHP. Every security advisory is a reminder you're running unsupported software. And every conversation with management about upgrading ends with "not this quarter".

Then, one day, you get the go-ahead.

This article is about what to do next. It's based on that Laravel upgrade, but the lessons apply to any framework and any big version jump. Two things made it possible: a good test suite, and automated refactoring. Everything else was about using those well.

Treat it as a project, not a task

The biggest mistake with a major upgrade is treating it as one big task: "upgrade the framework". It isn't. It's a project, and it has phases:

  1. Prepare the codebase before you touch the framework. I call this code prehab.
  2. Plan the upgrade path, and what you'll automate.
  3. Execute in small, systematic steps.

Let's go through each.

Phase 1: Code prehab

Prehab is what patients do before an operation: get as strong as possible beforehand, so recovery is quicker. The same idea works for codebases. The more work you do before the upgrade starts, the easier the upgrade will be.

There are three prehab activities: tombstones, type coverage, and test coverage.

Tombstones

Every line of code you upgrade costs time. If nobody runs that code, it's time wasted.

Old codebases are full of dead code. Features that were switched off years ago. Pages nobody links to. Discount codes for promotions that ended in 2017. The diff for our upgrade deleted nearly 20,000 more lines than it added.

The trouble is knowing what's actually dead. Guessing is dangerous. That admin report you've never heard of might be run once a year by the finance team, who will be very unhappy when it's gone.

This is where tombstones come in. A tombstone is a line of code added to the top of anything you suspect is dead:

final class PriceCalculator
{
    public function calculate(): void
    {
        TombstoneReporter::trigger('PriceCalculator::calculate');

        // Rest of method's code
    }
}

Deploy it to production and leave it. Every time a tombstone fires, it's recorded. After a while, you get a report of which tombstones were triggered.

  • Tombstone fired: the code is alive. Remove the tombstone, keep the code.
  • Tombstone never fired: the code is a candidate for deletion.

The reporter itself doesn't need to be clever, but it does run in production, so keep it cheap. The approach I used loads the list of already-triggered tombstones once per request, and only writes to the database the first time a tombstone fires. After that, a tombstone that's already been recorded costs almost nothing. A command then dumps the triggered tombstones to a JSON file for analysis. There's a working tombstone demo on GitHub.

Adding tombstones to hundreds of methods by hand would be tedious and error prone. Use a Rector rule to add them mechanically, and another to take them out again afterwards.

How long to leave them depends on your application. If there's anything seasonal, or anything run once a year, you need to allow for it.

When you do delete code, use commit messages you can search for:

REMOVE: Product listing page
REMOVE: 2 for 1 discount code

If it turns out something was needed, you can find the commit and revert it in minutes.

Type coverage

Type coverage is the proportion of your parameters, return types and properties that have type information, either native types or docblocks.

Here's a function with 0% type coverage:

function getNames($users)
{
    $names = [];
    foreach ($users as $user) {
        $names[] = $user->getUser();
    }
    return implode(', ', $names);
}

And here's the same function with 100%:

/**
 * @param array<int, User> $users
 */
function getNames(array $users): string
{
    // ...same body
}

Why does this matter for an upgrade?

Imagine the framework renames a method, getUser() to getUsername(). In the first version, no tool can know what $user is. It could be any class with a getUser() method. So you can't safely automate the rename. You have to find and check every call by hand.

In the second version, tools know $user is a User. The rename can be completely automated.

And here's the really important bit. With 100% type coverage, if the code worked before a refactor like this, the exact same code will work afterwards. The tools know every place getUser() is called on a User, so every one of them gets renamed. Nothing is missed, and nothing else is touched.

That means, for these kinds of refactors, you don't actually need tests to know the change is safe. I'd still recommend having tests, for all the reasons in the next section. But type coverage gives you a guarantee that tests can't: tests only check the paths they exercise, while the type information covers every call in the codebase.

That's why type coverage is so important. It's what allows us to refactor safely, and it's what makes automated refactoring trustworthy. The more type coverage you have before the upgrade, the more of the upgrade you can hand to tools.

On legacy code, adding types in docblocks first is a safe way to start. It gives the tools the information they need without changing runtime behaviour.

Test coverage

I'll be blunt. The upgrade wouldn't have been possible, or anywhere near as smooth, without tests.

A major upgrade touches almost everything. Without tests, you have no way of knowing if it still works except clicking through every page and hoping. With tests, you can make sweeping changes and find out within minutes whether you've broken something.

If your test coverage is poor, prehab is the time to fix it. Focus on the business-critical paths first.

Phase 2: Plan

Work out the order

Frameworks, PHP versions and tools all constrain each other. The new framework version needs a newer PHP. The newer PHP needs a newer PHPUnit. Your current framework version might not support that PHPUnit.

The trick is to build a table and find a path where every step is a supported combination.

Here's a simplified example with made-up version numbers:

                                 Framework   PHP          PHPUnit
Currently deployed               12          8.1          8
Supported by current framework   12          8.1 to 8.2   8 to 9
Supported by target framework    13          8.2 to 8.3   9 to 10

Reading across, a safe order is:

  1. Upgrade PHPUnit 8 → 9. (Still supported by framework 12.)
  2. Upgrade PHP 8.1 → 8.2. (Still supported by framework 12.)
  3. Upgrade the framework 12 → 13. (Now everything is in the supported range.)

At every step, you're on a combination somebody has tested. And at every step, you could deploy.

Avoid the big bang

That last point matters a lot.

A big-bang upgrade, where you create a branch and don't merge it until everything's done, means stopping feature development. The longer the upgrade takes, the longer the business goes without new features. That's exactly what makes management reluctant to agree to upgrades in the first place.

Wherever you can, do several smaller upgrades with release points between them. Make the minimum number of changes needed to get to the next point where you could deploy.

Here's the most important lesson from this project: whatever you think the smallest chunk of work is, you're probably wrong. You can almost certainly make it smaller.

The biggest mistake I made on this upgrade was doing too much in one step. At the time, a lot of the work felt too intertwined to split up. Looking back, it wasn't. I could have broken it into much smaller steps, many of which could have been released while we were still running Laravel 4.2.

Here's an example. Laravel 4.2 didn't put application code in namespaces. Controllers, models and the rest all lived in the global namespace. From Laravel 5 onwards, they live in namespaces under App.

We made that change as part of the big upgrade. Instead, we could have done it before the upgrade even started, one piece at a time:

  1. Move all the controllers from the global namespace into a controllers namespace. Release it, still on Laravel 4.2, and check everything works.
  2. Do the same for the models. Release, check.
  3. Carry on through the rest of the codebase, moving each part from where it lives in 4.2 to where it will live in 9.

Each of those is a single, small pull request that can be released on its own. If something breaks, you know exactly which change caused it, and it's small enough to fix or revert quickly. By the time you start the framework upgrade itself, a big chunk of the diff has already been done, tested and deployed.

Lots of baby steps would have been a much better approach than the big bang I took. So before you start, go through your plan and, for every step, ask: could this be done now, on the current version, and released on its own? More often than you'd expect, the answer is yes.

Even so, some parts of a big upgrade can't be split, and you'll end up with a branch that lives for a while. There's a way to shorten that pause, which I'll come to in a moment.

Decide what to automate

For every kind of change the upgrade needs, ask these questions in order:

  1. Should it be automated? Some changes need human judgement.
  2. Is there an existing solution? If so, use it.
  3. If not, is it worth automating yourself? That depends on how many times the change needs to be made.
  4. Otherwise, do it by hand.

A change that's needed five times: do it manually. A change that's needed five hundred times: automate it.

For "existing solutions", there are good options:

Rector is an automated refactoring tool for PHP. It has hundreds of rules, and rulesets that group them. There are rulesets for PHP version upgrades in Rector itself, and community rulesets for frameworks: rector-symfony and rector-laravel.

Framework rulesets mirror the official upgrade guides. For example, Laravel 9 swapped SwiftMailer for Symfony Mailer, and the upgrade guide tells you to rename withSwiftMessage() to withSymfonyMessage(). The Laravel 9 Rector set does that rename for you:

use RectorLaravel\Set\LaravelSetList;

return RectorConfig::configure()
    ->withPaths([__DIR__ . '/app'])
    ->withSets([LaravelSetList::LARAVEL_90]);

Laravel Shift is a commercial service that automates Laravel upgrades.

And the official upgrade guides are still your playbook. Read them for every version you pass through. Symfony has a well defined process for major upgrades: move to the highest minor version, fix every deprecation warning, then upgrade the major version.

Write your own automation for the rest

Where there's no existing rule and the change is repeated a lot, write a custom Rector rule.

Here's one from our upgrade. In Laravel 4, model relationships used strings:

return $this->belongsTo('User');

We wanted class constants:

return $this->belongsTo(App\Models\User::class);

There were hundreds of these. And there were belongsTo() calls on classes that weren't models, which had to be left alone.

A Rector rule works on the abstract syntax tree (AST) of your code. You tell Rector which type of node you're interested in, and write a refactor() method that returns either the modified node, or null for "leave it alone":

final class FixModelMappingsRector extends AbstractRector
{
    public function getNodeTypes(): array
    {
        return [MethodCall::class];
    }

    public function refactor(Node $node): ?Node
    {
        // Is the method called on an object that extends Model?
        if (!$this->isObjectType($node->var, new ObjectType(Model::class))) {
            return null;
        }

        // Is the method called belongsTo?
        if (!$this->isName($node->name, 'belongsTo')) {
            return null;
        }

        // Is the first argument a string literal?
        $arg = $node->args[0] ?? null;
        if (!$arg instanceof Arg) {
            return null;
        }
        if (!$arg->value instanceof String_) {
            return null;
        }

        // Replace 'User' with App\Models\User::class
        $fqcn = 'App\\Models\\' . $arg->value->value;
        $arg->value = new ClassConstFetch(new Name($fqcn), new Identifier('class'));

        return $node;
    }
}

Notice the structure. Every check that fails returns null. The actual change only happens at the very end, when we're sure it's the right node. And notice the first check: isObjectType() relies on type information. That's the prehab paying off.

Rector's vendor/bin/rector custom-rule command scaffolds the rule, a test, and fixture files, so you can develop rules test first. I've written more about custom Rector rules in Rector Isn't Just for Upgrades, and the full example is on GitHub.

Shorten the pause with automation

Here's the trick for when a long upgrade branch is unavoidable.

Write the automation scripts before you pause feature development, alongside normal work. Custom Rector rules, configuration, scripts. Test them against the current codebase.

Then, when you do pause, the upgrade itself is much shorter, because a lot of the work is running scripts you've already written.

There's a bonus. On a long-lived branch, the main branch keeps moving. Bug fixes and small features get merged. If your mechanical changes are automated, you can just merge main into the upgrade branch and rerun the scripts, instead of repeating hundreds of manual edits.

Phase 3: Execute

One kind of change at a time

Be systematic. Make one type of change, run the tests, commit. Then the next.

This applies to manual changes as much as automated ones. If you're renaming a method, only rename that method. Don't also tidy up the code around it.

You'll constantly spot other things that need fixing. Don't fix them. Add a marker, something like:

// TODO L9: this should use the new query builder syntax

Then come back to them later. Mixing unrelated changes together makes it much harder to work out what broke when a test fails.

Commit often

Commit after every step, even if you plan to squash them later. When something breaks three steps on, you'll want to be able to go back.

Fix deprecations, don't adopt new features (yet)

This one's counter-intuitive. The new framework version has lots of nice new features. It's tempting to start using them as you go.

Don't. Not yet.

Fix what the upgrade requires: removed features, changed signatures, deprecations. Leave everything else alone. The code won't be as idiomatic as it could be straight after the upgrade, but the diff will be smaller. And when you're hunting for the cause of a bug, a smaller diff means less code to search through.

You can adopt new features once the upgrade is stable and deployed.

Make the next upgrade easier

Once you're through it, think about how to avoid being here again.

Keep upgrading

The obvious one. Small, regular upgrades are much easier than one huge one. Put framework and PHP upgrades into your normal planning, rather than waiting until the gap is enormous.

Decouple business logic from the framework

The more your business logic depends on the framework, the more of your code every upgrade touches.

The alternative is to structure the application so the framework calls into your business logic, and your business logic talks to libraries through small interfaces that you own:

interface TextMessageGateway
{
    public function sendMessage(
        MobileNumber $from,
        MobileNumber $to,
        string $message,
    ): void;
}

The business logic only knows about TextMessageGateway. A thin adapter implements it using whichever SMS library you're using. When that library (or the framework) changes, you update the adapter. The business logic doesn't change at all.

A wish for framework authors

Here's an idea I floated in the talk. What if frameworks shipped their own upgrade automation, and Composer knew how to run it?

Something like a version-upgrade section in the framework's composer.json, listing for each major version the checks to run (PHPStan rules that flag code needing changes) and the automation to apply (Rector sets and scripts). Then:

composer update framework/framework:^2 --allow-upgrade-scripts

To be clear, this doesn't exist. It's a wish. But the pieces are all there, and framework rulesets for Rector already show how much of an upgrade can be automated.

The short version

If you only remember a few things:

  • Tests first. They're the single biggest enabler of a safe upgrade.
  • Prehab. Tombstones, type coverage, test coverage.
  • Plan the order so every step is a supported combination you could deploy.
  • Make the steps smaller. Whatever you think the smallest chunk is, you can probably split it further. Release as much as you can before the upgrade itself.
  • Automate the repetitive changes. Existing Rector rules first, custom rules for the rest.
  • One change at a time. Commit often. Leave new features for later.

Where to start

Even if an upgrade isn't on the horizon, prehab is worth doing now. Add tombstones to the code you suspect is dead. Add types to the code you touch. Add tests to your most important paths.

When the go-ahead finally comes, you'll be ready.

The slides are on my website, and the book Rector: The Power of Automated Refactoring by Matthias Noback and Tomas Votruba is an excellent next step.

What's the oldest framework version you've had to upgrade from?

/// About the author

Dave Liddament

Director at Lamp Bristol, writing software commercially for 24+ years. Speaks at international PHP conferences, wrote SARB and PHP Language Extensions, and organises PHP-SW. He teaches this material in our team training workshops.

/// More articles

Your Pull Requests Already Know How Your Project Is Going

Read the article →

PHP Has Had Generics for Years. You're Probably Just Not Using Them.

Read the article →

Rector Isn't Just for Upgrades. Most Teams Are Using a Tenth of It.

Read the article →

Start a conversation

We help teams put these ideas into practice, through training, code review and working alongside you. Tell us where you are and what you're aiming for.