Legacy code refactoring is the controlled improvement of old code structure without changing its behavior — no rewrite from scratch, no production downtime. Done well, refactoring lowers the cost of every future change, shortens release times and reduces the number of bugs. This guide shows how to plan it, which techniques work in old PHP code, and when refactoring stops paying off.
Key takeaway: Refactoring does not change what the system does — it changes how the system is built. There is one safety rule: safety-net tests (characterization tests) first, refactoring second, module-by-module deployment last. Without tests, refactoring is not modernization — it is gambling.
What refactoring is and what it is not
Refactoring is changing the internal structure of code while keeping its external behavior identical. Martin Fowler defines it as "a disciplined technique for restructuring an existing body of code, altering its internal structure without changing its external behavior" — and that condition is the essence, not a decoration. If the system behaves differently after the change (even "better"), it is not refactoring but a functional change that needs its own process and its own tests.
Refactoring is not
- rewriting a module from scratch — that is replacing the implementation, a different (and riskier) decision,
- performance optimization — refactoring can make it easier, but it does not chase milliseconds by itself,
- formatting cosmetics — style cleanups are done by tooling, not as a modernization project,
- adding features — new behavior needs new tests and usually a different release cycle.
Refactoring is an investment in the cost of future changes. Every module refactored into a readable structure makes the next twenty changes in that module cheaper, faster and safer.
When to refactor and when to just patch
Not every fragment of old code deserves refactoring — and that is the first decision worth making consciously. In the projects I take over, I split code into four categories:
- critical and frequently changed — refactor first, because every change hurts most here,
- critical but stable — cover it with tests and leave it; touch it only when there is a real need,
- non-critical and frequently changed — refactor while making functional changes anyway,
- dead code — delete it, do not refactor it. Cleaning up unused code is the most common modernization trap.
The starting point is always understanding the system — a dependency map, a list of critical modules and their change history. Without that, refactoring turns into tidying random files. I describe the full modernization plan — from audit to CI/CD — in Legacy Code Modernization — Step by Step.
Safety-net tests — a necessary condition
Refactoring without tests is dangerous for a simple reason: if you do not know what the system does now, you cannot tell whether it still does the same thing after the change. Old code rarely has unit tests, but that does not leave you without tools.
The proven approach is characterization tests — you describe the current behavior of the system in tests, including the imperfect parts, and treat them as a contract. In practice, this pairs with end-to-end tests on critical paths: login, checkout, payment, invoicing. Those few tests give more safety than a week of analysis.
Only when a module has a safety net can it be touched. The order is non-negotiable — and that is the difference between refactoring and improvisation.
Refactoring techniques that work in old code
In legacy code, small-scope techniques work best — each one is reversible and verifiable through tests
- extract method — the simplest and most effective technique; it breaks long procedures into steps with names that describe intent,
- branch by abstraction — you insert an interface between the old and new implementation, so implementations can be swapped without touching call sites,
- dependency inversion — instead of global function calls and static classes, you introduce dependency injection, which makes code testable,
- removing duplication — repeated blocks become shared functions; in old code, duplication is the main source of "fixed it in one place, broke it in three,"
- naming cleanups — variables like
$data2,$tmpand functions likeprocessStuff()cost more time than any architecture decision, - isolating side effects — separating database writes, email sending and API calls from business logic.
For large monoliths, the overarching technique is the Strangler Fig pattern — new code grows around the old and takes over its functions module by module.
Refactoring supported by tools
Manual refactoring of a large system takes months. Automation changes the economics of the whole process
- Rector performs bulk refactorings and PHP syntax upgrades — changes that would take a quarter by hand can be prepared as a pull request in an hour,
- PHPStan and Psalm catch type errors and dead code before runtime; in legacy code you start at the lowest level and raise the bar module by module,
- PHPUnit and Pest lock in module behavior before changes,
- IDEs (PhpStorm, VS Code) with static analysis safely perform renames, signature changes and code moves,
- Laravel Shift automates framework upgrades between versions if the system uses Laravel.
The starter kit is free or cheap: PHPStan, Rector and PHPUnit are open source.
The refactoring process step by step
A proven cycle for a single module
- Pick a module — critical for the business and frequently changed, but not the hardest one in the system. The first module is meant to give the team a process, not a medal.
- Cover it with tests — characterization tests for current behavior, end-to-end tests for the user journey if the module touches one.
- Refactor in small steps — every step ends with a test run. Commit after every green step.
- Deploy to staging — let the new version sit under test traffic.
- Deploy to production and monitor — errors, performance, logs. A few days of observation before the module is considered done.
- Add unit tests for the newly extracted fragments — you repay technical debt as you go, not "someday."
Pace matters less than rhythm. One module per sprint, deployed and verified, beats three modules refactored "almost done" and deployed together.
When refactoring is not worth it
-
An honest answer: sometimes refactoring is not the right tool.
-
The module is beyond saving and its logic is simple — it is cheaper to rewrite it from scratch with tests than to refactor it layer by layer.
-
The code works and nobody changes it — every hour of refactoring such a module is a cost with no return; leave it with safety tests and monitoring.
-
A full system replacement is coming — if the company has decided to migrate to a new platform within a quarter, refactoring the old code is work thrown away.
-
There are no tests and no time to write them — refactoring without a safety net is not modernization; in that situation you start with tests, not with structural changes.
Refactoring is a tool, not a goal. The goal is a system that can again be developed in a predictable time and budget.
Frequently asked questions
How long does legacy code refactoring take?
It depends on scale and test quality. Refactoring a single module usually takes a few days to two weeks — including characterization tests and deployment. A whole system is split into modules and refactored in stages over months, without stopping production.
Does refactoring require system downtime?
No. Modular refactoring keeps the system running the whole time — changes go through staging and are deployed one at a time, with monitoring after each release. Downtime is not part of the process.
How is refactoring different from a rewrite from scratch?
Refactoring improves the structure of existing code while preserving its behavior and technology. A rewrite builds a new system beside the old one, with new technology decisions — it is more expensive, takes longer and carries more risk. Most systems need refactoring, not a rewrite.
Can refactoring break a working system?
It can, if you run it without tests and without module-by-module deployments. With characterization tests, staging deployment and post-release monitoring, the risk is limited to a single module — not the whole system. It is exactly the same safety standard you apply to any other change in production code.