Migrating a legacy PHP app without a rewrite
Rewrites are slower and riskier than they look. A strangler approach modernises a legacy PHP application in reviewable steps while it stays in production.
The rewrite pitch is always the same: the old code is unmaintainable, a clean build will take four months. It takes eighteen, and for most of that time you are maintaining two systems while shipping no new value.
The estimate is not wrong because the team is optimistic. It is wrong because the existing system’s behaviour is not written down anywhere — it is distributed across ten years of small decisions, and the only complete specification of it is the code you are proposing to delete.
The alternative
Put the legacy application behind a router. Add new functionality in a modern codebase alongside it. Move existing functionality across one route at a time, oldest and least risky first. Delete the legacy path only after the replacement has been in production long enough to be trusted.
This is the strangler pattern, and its virtue is not elegance. It is that every step is independently valuable and independently reversible. If the budget disappears after four routes, you have four modernised routes and a working system — not half a rewrite and a decision about whether to continue funding it.
What makes it work
- A test harness around revenue paths before touching anything.
- Characterisation tests that record current behaviour, including behaviour that looks like a bug — some of it is load-bearing.
- Reviewable batches. If a change cannot be reviewed in an hour, it is too big.
The mechanics
The router is the whole architecture. Everything else follows from having one place that decides whether a request goes to the old system or the new one. A reverse proxy in front of both is usually simplest, with a rule per path. Keep the rules boring and readable; this file is the map of the migration, and people will read it for years.
Sessions and auth have to be shared from day one. This is the part that sinks strangler migrations. If a user logging in on the new system is logged out on the old one, you cannot move routes incrementally — you can only move all of them at once, which is the rewrite you were avoiding. Shared session storage, or a token both sides validate, is prerequisite work, not a later refinement.
The database stays shared, initially. Two applications on one schema is not architectural purity, and that is fine. Splitting data ownership is a second migration, and doing both at once means every bug has two possible causes. Move the code first, and only then decide whether the data needs to move too.
Write characterisation tests before you understand the code. Feed it real inputs, record what it returns, assert that it keeps returning it. You are not documenting correct behaviour — you are documenting current behaviour, which is the thing customers have built their processes around. Weird rounding on partial refunds might be a bug, or it might be the reason a decade of accounts reconcile. Find out before you fix it.
Pick the first route for information, not for value. The first migration exists to prove the router, the shared session, the deploy path and the rollback. Choose something low-traffic and low-risk, get it into production, and let it run for a fortnight. Starting with the highest-value route means learning the plumbing during the incident.
The trade-offs
You maintain two systems at once, deliberately. Two deploy pipelines, two dependency sets, two places to check when something breaks — for the entire duration, which is usually longer than a rewrite’s optimistic estimate and much shorter than its actual one. This is a real cost, paid to keep the system in production and the revenue flowing.
Progress is unglamorous and hard to demonstrate. There is no launch. There is a slowly growing set of routes on the new stack, which is difficult to celebrate and easy to defund because nothing visibly changed. Track and publish the percentage of traffic served by the new stack, or the work becomes invisible.
A hybrid can become permanent. If the last 20% is genuinely hard, the migration stalls there and the router becomes forever. Sometimes that is the correct economic answer — a stable, ugly, working module is not an emergency. But it should be a decision someone makes on purpose, not a state you drift into and stop noticing.
Some duplication is unavoidable. Validation rules, formatting, a couple of domain calculations will exist in both codebases during the overlap. Accept it, list it somewhere visible, and keep the list short.
When a rewrite is right
When the platform itself is gone — an unsupported runtime with no upgrade path, or a framework with no security releases. Even then, migrate module by module rather than switching over on one date.
Also when the requirements have genuinely changed shape: if the new system is a different product rather than the same product in better code, you are not rewriting, you are building, and the comparison to the old system is mostly a distraction. Be honest about which one you are actually proposing — the two get conflated constantly, and the confusion is how eighteen-month projects get sold as four-month ones.
This is the default approach on our PHP and Laravel engagements, and it pairs well with putting a modern front end on a system whose business logic is fine and whose interface is not.
Sources and further reading
// TAGS
// RELATED POSTS
(02) // LET'S BUILD
START APROJECT
