Back to blog

How to Modernise Legacy PHP: Do Absolutely Nothing to the Code for Two Months

6 min read
On this page

A conversation we have a few times a year starts with someone forwarding us a codebase and asking what it would cost to modernise it. Sometimes it is vanilla PHP from the mysql_* era, held together by include statements and a file called functions.php that is nine thousand lines long. Sometimes it is a framework that was current when it was chosen, an old CodeIgniter or a Symfony 2 or somebody's in-house MVC from 2011, and the person who understood it left in 2019. Either way the question is the same, and the expected answer involves an architecture diagram.

Our answer is usually disappointing, because before we talk about architecture at all, we want to know whether you can deploy this thing twice in an afternoon without anybody's heart rate changing.

Most of the time the answer is no, and that tells you what the actual first project is.

So here is the advice, and nobody has ever been pleased to receive it: leave the code alone for about two months. Do not refactor it, do not restructure it, do not start extracting classes out of that nine-thousand-line file, and do not upgrade the PHP version. Spend those two months making the application boring to operate, and only then start touching what is inside it. What follows is why that order is not just safer but genuinely faster.

Why everybody reaches for the rewrite

The appeal of a rewrite is obvious once you have spent a week inside a codebase like this. The code is genuinely bad, nobody enjoys working in it, and a rewrite offers the fantasy of a clean slate where every decision is one you would make today. It also has the enormous political advantage of being a story you can tell a board: we are replacing the old system with a new one, here is the timeline, here is the budget.

The trouble is that a rewrite asks you to reproduce fifteen years of accumulated business logic, most of which is undocumented, some of which exists because of a specific tax rule in one country, and all of which someone will notice the moment it stops working. You are also trying to hit a moving target, because the legacy system does not stop changing while you rebuild it. This is the well-worn path to a project that consumes two years and then gets quietly shelved because the new system never quite reached parity.

The strangler fig pattern exists as an answer to exactly this, and it is a good pattern. You put a routing layer in front of the old application, build new functionality behind it as separate services or controllers, and gradually move traffic across until the old code is doing nothing and can be deleted. Every article on legacy PHP will tell you about it, usually with a diagram involving a tree.

Here is what those articles skip. The strangler fig pattern requires you to deploy frequently, roll back instantly, and know within minutes when a migrated route starts throwing errors that the old one did not. If you cannot do those three things, incremental migration is not safer than a rewrite. It is a slow-motion outage with extra steps.

Where we are coming from

We have done this work, and we will do it again when a client asks, whether that means a careful incremental migration or a genuine rewrite where one is warranted. We also run the infrastructure underneath a lot of applications we did not write, which is the less common half of the combination and the reason our sequencing looks different from most advice you will read.

Most writing about legacy code comes from people who refactor for a living, so it quite naturally starts at the code. We spend as much time on the layer below, and the view from down there is unambiguous: legacy PHP modernisation projects mostly fail for operational reasons rather than architectural ones. The pattern is not that somebody chose the wrong design. It is that the team could not deploy safely enough to execute the design they chose, so progress slowed, confidence drained away, and eventually the project got quietly parked.

That is a fixable problem, and it is much cheaper to fix than the architecture. It just has to be fixed first.

Phase zero: make it boring

Before anyone touches application architecture, five things need to be true. None of them are interesting, and all of them are cheaper than the rewrite you were considering.

It has to be in version control, properly

We still find production code that exists only on the production server, or in a repository that diverged from what is actually running about three years ago. Sometimes there is a public_html_old directory sitting next to the live one, which is a form of version control in the same way that a pile is a form of filing.

The test is not whether a repository exists. It is whether you can check out the current commit onto a clean machine and get the same application that is serving traffic right now. Until that is true, every subsequent step is guesswork, because you do not actually know what your codebase is.

Deploys have to be repeatable and reversible

If deployment involves FTP, or a human running commands in a specific order from memory, or a step described in a wiki page as "then fix the permissions," you cannot iterate safely. That means you cannot do incremental modernisation, because incremental modernisation is just a very long sequence of small deployments.

What you want is unremarkable. A tagged commit goes out through a scripted process, the previous release stays on disk so a rollback is a symlink change rather than a restore, and any one of your developers can run it on a Friday afternoon without a meeting. The technology barely matters. What matters is that the process is identical every time and does not depend on which person is doing it.

# The shape of it, not a recommendation of any particular tool
releases/
  20260811-143022/   <- current, symlinked from /var/www/app
  20260804-091544/   <- previous, one symlink away from being current again
  20260728-160311/
shared/
  storage/
  .env

Once a rollback costs you four seconds and no thought, your appetite for touching risky code changes completely. That psychological shift is the actual deliverable here.

Backups have to exist and restores have to be tested

We have written about this at length in Your Backups Are Useless If You've Never Tested a Restore, so we will keep it short: an untested backup is a hypothesis, and legacy modernisation is precisely the activity most likely to test it for you. The first time a Rector rule does something clever to a query builder and you need yesterday's database, you will find out whether your backup strategy was real.

Take a backup, restore it somewhere else, point a copy of the application at it, and confirm the thing boots. Do that before you change a single line.

You need monitoring and error tracking

This is the step that gets skipped most often, and skipping it is what turns a careful migration into a mystery. When you move a route from the old code to the new code, you need to know immediately if that route starts throwing exceptions that nobody noticed in testing, and you need to know if it got slower, because legacy applications are full of accidental behaviour that turns out to be load-bearing.

Legacy PHP is often unusually rewarding to instrument, because these codebases tend to have been swallowing errors for years. Somewhere there is an error_reporting(0) or a bare try { } catch (Exception $e) { } that has been quietly eating problems since 2016. Turning on real error tracking on an old application is frequently uncomfortable, in the sense that you discover the app has been throwing four hundred exceptions a day and nobody knew.

That discovery is the point. You cannot judge whether a refactor made things better if you had no measurement before it.

We build Tindra for this, so treat that as the disclosure it is, but the specific tool matters far less than having one. Sentry, GlitchTip, or anything else that speaks the same protocol will do the job. What you cannot do is fly blind through a migration and hope your users tell you.

You need a staging environment that resembles production

Not an approximation, and not a developer's laptop. The same PHP version, the same extensions, the same database engine and version, the same web server configuration. Legacy applications are exquisitely sensitive to environment details, because they were written against whatever happened to be installed at the time and they encode assumptions nobody wrote down.

If staging runs a different PHP minor version than production, staging will lie to you, and it will lie specifically about the things you are trying to change.

Only now, look at the code

With those five things in place you have a safety net, and the interesting work can start. The order we favour is analysis, then automated change, then human refactoring, then the runtime upgrade.

Start by finding out what you actually have. PHPStan will tell you, and it will tell you more than you want to hear.

composer require --dev phpstan/phpstan
vendor/bin/phpstan analyse src --level=0

Level 0 on a fifteen-year-old codebase will still produce a wall of output, which is why starting anywhere higher is a mistake. Fix what level 0 finds, commit, then move to level 1. Treat each level as its own small project rather than a target to leap at, and generate a baseline so that existing problems do not drown out new ones you introduce.

Once static analysis gives you a rough map, Rector can do a surprising amount of the mechanical work. It parses your code into an abstract syntax tree, applies rules, and rewrites files, and it handles upgrades from PHP 5.3 all the way up to 8.5. It also uses PHPStan under the hood to understand types, which is the practical reason the two belong together: what PHPStan can see, Rector can act on.

<?php
// rector.php
use Rector\Config\RectorConfig;
use Rector\ValueObject\PhpVersion;

return RectorConfig::configure()
    ->withPaths([__DIR__ . '/src'])
    ->withPhpVersion(PhpVersion::PHP_74)  // one target at a time
    ->withPreparedSets(deadCode: true)
    ->withSkip([
        __DIR__ . '/src/Legacy/TheFileNobodyUnderstands.php',
    ]);

Worth knowing that Rector reads the target PHP version from your composer.json by default, and overriding it explicitly is unusual, so if your composer.json still claims PHP 5.6 then that is what Rector will aim at. Fixing that file is often the actual first step.

The discipline that matters is moving one version at a time and reading every diff. Rector is good, and it is not a substitute for understanding what changed. Run it, read the diff, run your tests, commit, then take the next step. Rector's own documentation suggests getting to PHPStan level 3 or 4 before leaning on it heavily, which matches our experience: the better its type information, the less it has to guess.

Somewhere in this phase you also start writing tests, and the useful thing about legacy code is that you do not need to test everything. Test the paths that make money and the paths that would embarrass you, and characterise the existing behaviour rather than the behaviour you wish existed. A test that pins down a weird calculation you do not fully understand is more valuable than a beautiful unit test of a class you already trust.

The PHP version upgrade comes late

This is where we differ from a lot of the advice out there, and it is worth being explicit about why.

Running an end-of-life PHP version is a genuine security problem. As of August 2026, only the 8.2, 8.3, 8.4, and 8.5 branches receive any updates at all, and 8.2 is on security-only support that expires on 31 December 2026. Anything on 8.1 or below, and certainly anything still on 7.4, is receiving no patches from anyone and has not been for some time. If that describes your application, the pressure to upgrade immediately is real and we understand it.

Upgrade it anyway only once you have tests. A runtime upgrade changes behaviour in places you will not predict, particularly around type coercion, string-to-number comparison, and anything that was relying on a deprecation that finally became an error. Without a test suite, the upgrade becomes a live experiment on your users, and you will spend the following fortnight fixing symptoms without knowing whether you have found all of them.

If your PHP version is so old that it constitutes an active emergency, the correct move is usually to reduce exposure while you build the safety net rather than to skip the safety net. Put a WAF in front of it, tighten what is reachable from the internet, restrict the network paths in and out of that server, and buy yourself the weeks you need to do the upgrade properly. That is a smaller, faster project than an unhedged runtime jump, and it does not gamble the application.

Where you end up depends on the client

We do not have a house answer to what the destination should be, and we are suspicious of anyone who does.

Some of these projects end up on Laravel, and when the application is genuinely a web application with the usual concerns, that tends to be a comfortable home. Others take Laravel's components without the framework, pulling in the illuminate packages that solve a specific problem and leaving the rest alone, which works well when the application has an unusual shape that fights conventional routing. And some of them stay as vanilla PHP that is simply organised properly: an autoloader, real classes, dependency injection where it helps, tests around the important paths, and no framework at all.

That last outcome is more common than the framework-shaped internet suggests, and it is not a failure. A well-structured PHP application with tests and a sane deploy pipeline is in far better shape than the same application rewritten onto a framework that its team does not know. The destination should follow from what the application does and who has to maintain it after you leave, not from what is currently fashionable.

When a rewrite genuinely is the answer

We are not against rewrites categorically, and there are cases where incremental work is the wrong call.

If the application's data model is fundamentally wrong for what the business now does, no amount of careful refactoring fixes that, because you are not fighting the code. If the business requirements have shifted so far that most of the existing logic is obsolete, then preserving it is the mistake. If the codebase is small enough that a rewrite is genuinely a matter of weeks, the risk calculation changes completely. And if the code depends on something with no path forward at all, an abandoned commercial framework or a PHP extension nobody maintains, then incremental migration eventually walks into the same wall.

What all of those have in common is that the reason is structural rather than aesthetic. "This code is ugly and nobody likes it" is not on the list, and it is the reason most rewrites actually get proposed.

The two months, concretely

If you want a plan rather than a philosophy, this is roughly how those eight weeks go, and none of it involves touching application logic.

Weeks one and two. Establish that what is in your repository is what is running in production, and reconcile it if it is not. This is dull archaeology and it occasionally turns up genuinely alarming discoveries, which is exactly why it goes first.

Weeks three and four. Build a deploy process that any developer can run and that rolls back by moving a symlink. Use it for something trivial, like a copy change, so that the first real deploy is not also the first ever deploy.

Week five. Take a backup, restore it onto different hardware, point a copy of the application at it, and confirm the thing boots and serves a page. Write down how long that took, because that number is your actual recovery time.

Weeks six and seven. Turn on error tracking and then leave it alone. The waiting is the work here: you need a baseline of what this application does on a normal Tuesday before you can tell whether anything you do later made it better or worse. This is also when you find out about the four hundred daily exceptions nobody knew about.

Week eight. Build a staging environment that matches production down to the PHP minor version and the installed extensions.

Now you can install PHPStan at level 0 and start reading, with a safety net underneath you and a baseline to compare against.

Two months is a real cost and we are not going to pretend it feels productive, because for most of it you will have nothing to show a stakeholder except a deploy script and some graphs. It is still faster than the alternative, which is eighteen months of a rewrite that gets shelved at seventy percent parity.

If you would rather not do this yourself

We can take any part of this, or all of it. Deployment pipelines, monitoring, backup strategy, and staging environments that match production sit inside our server management and monthly retainer packages. The modernisation work itself, whether that is a PHP version upgrade, an incremental migration onto Laravel, or a rewrite where the data model genuinely demands one, is what our development team does.

Plenty of clients want us to build the safety net and then run the refactoring with their own developers, which works well and is often the right split. Others hand us the whole thing. Either is fine.

If you want a second opinion on whether your codebase needs a rewrite or a deploy script, talk to us. The first thirty minutes are free, and we will tell you honestly if the answer is "less than you think," because that answer costs you less and we would rather have the longer relationship.

Written by

Blendbyte

Blendbyte Team

We run what we write about. Production experience only, no theory.

Need help with your setup?

We build and run infrastructure for clients every day. If you need help with your server, cloud, or software setup, talk directly to the engineers who do the work.

Let's talk