Modernizing a legacy PHP application without rewriting the whole system
The rewrite from scratch is the strategy that fails most often. How to modernize a PHP application step by step: functional boundaries, the strangler fig pattern, a test safety net, and risk reduction batch after batch.
By the Synapsea team · · 4 min read
A ten- or fifteen-year-old PHP application runs the business. It has also become hard to change: end-of-life PHP version, abandoned or home-grown framework, no tests, business rules buried in thousand-line controllers. The temptation is to start over. It is usually the worst option: by the time the new application catches up with the old one, the business has moved, the budget is spent, and the two systems coexist forever.
Here is the progressive approach we apply, and its rules.
1. First, make the present observable
You cannot modernize what you cannot measure. Before touching the code:
- Inventory: versions (PHP, framework, extensions), dependencies and their maintenance status, entry points (URLs, cron jobs, queues, partner interfaces), database schema and its actual usage.
- Logging: if the application does not log, add a minimum (incoming requests, errors, response times) to learn what is really used. A share of the code is often dead.
- Characterization tests: tests that describe current behavior, quirks included, on the journeys that matter. They do not judge the code; they pin down what users expect.
This foundation is the deliverable of the diagnosis. It supports an informed decision and serves as the safety net for everything that follows.
2. Upgrade the language before the framework
PHP first. Moving from PHP 5.6 or 7.x to PHP 8.4 is an operation in itself: removed functions, stricter typing, vanished extensions (mysql_*, ereg, mcrypt). It is done without changing the framework, with tooling:
- Rector with the PHP rule sets (
->withPhpSets()) for the mechanical changes; - PHPStan at increasing levels to find what Rector cannot see;
- a Docker image at the target PHP version to run the characterization suite.
Result: an application with identical behavior on a maintained language (PHP 8.4 is in active support until December 31, 2026, then security-only until December 31, 2028). Many applications stop here for a while, and that is a sound decision.
3. Draw functional boundaries
The real modernization work is cutting things up. Not by technical layer ("let's redo the whole database first") but by functional boundary: billing, catalog, user accounts, exports. For each one, identify:
- its entry points (URLs, commands, messages);
- the tables it owns and those it only reads;
- its dependencies on other boundaries.
Boundaries with few dependencies and high value are the first candidates. Stable, rarely touched ones can stay in the old code for a long time.
4. The strangler fig pattern, concretely
The "strangler fig" pattern, described by Martin Fowler, grows the new application around the old one, feature by feature, until the old one is empty. In PHP:
- A routing façade receives every request. At first it delegates everything to the old application — often a plain Symfony
index.phpwhose last fallback includes the legacy bootstrap. - One boundary is rewritten in the new code base (Symfony, Doctrine, tests). The façade routes its URLs to the new code; everything else still goes to the old one.
- Shared data has a single source of truth. As long as both code bases write to the same table, keep the schema, add constraints and document; schema migrations come later, once only one application writes.
- Measure: errors, response times, reported functional gaps. The batch is validated; move to the next.
The old and new applications coexist for months. That is intentional: there is never a global cutover.
5. Reduce risk on every batch
A few rules that prevent most accidents:
- One batch = one boundary = one production release. No three-month batches.
- No schema redesign while a boundary is being rewritten. One thing at a time.
- Characterization tests run before and after every batch, on top of the new code base's tests.
- Partner interfaces preserved byte for byte: systems that consume your exports or APIs must notice nothing. An interface contract (OpenAPI, file schema) becomes a test.
- Rollback available: the façade can send a boundary back to the old code within minutes.
- Documentation as you go: every batch leaves a page saying what moved, where and why.
6. When a full rewrite is justified
Cases exist: a very small application, total coupling with no identifiable boundary, or an incompatible foundation (PHP 4, an extension gone with no alternative). Even then, keep the characterization safety net and ship the new application in visible pieces, not as one block.
Takeaways
- Observe before changing: inventory, logs, characterization tests.
- PHP first, framework second; the two operations do not mix.
- Cut along functional boundaries and let old and new code coexist behind a façade.
- Ship every batch to production, with rollback available.
Going further: our PHP & Symfony modernization offer describes three engagement levels, from diagnosis to accompanied migration, and our article on the Symfony 6.4 to 7.4 migration details the framework step.
Sources: "StranglerFigApplication", Martin Fowler (martinfowler.com); supported PHP versions (php.net/supported-versions); Rector documentation (getrector.com); PHPStan documentation (phpstan.org).