Taking over a legacy PHP codebase: diagnosis and mapping across 5 axes
What we actually see at a legacy client
When we audit a PHP platform that nobody fully understands anymore, the scenery is always the same. A codebase that is ten to fifteen years old, a Zend Framework 1 or a Symfony 2 that was never upgraded, Composer dependencies pinned to abandoned versions, zero automated tests, documentation built out of half-dead Confluence pages and Post-its stuck on monitors. The original developers left a long time ago. The current team ships features while praying not to break anything.
That code produces revenue. Sometimes tens of millions of euros a year. It cannot stop. It cannot be rewritten on a whim either: the big bang kills one client out of two.
Our job is to take over these codebases without breaking the business and move them to a modern foundation in eighteen to thirty-six months. This article describes the method we apply on every takeover engagement.
What "legacy" really means
The popular definition is wrong. Legacy is not "old code". Legacy is code that scares whoever has to change it. That fear has precise causes, and identifying the dominant cause shapes the plan.
- Functional fear. Nobody knows what the code is supposed to do. Business rules are buried in fifteen-deep nested ifs.
- Technical fear. The code relies on abandoned patterns (globals, static registries, homegrown ActiveRecord), deprecated APIs, dependencies that nobody supports anymore.
- Regression fear. No tests, no safety net. Every deploy is a lottery.
- Operational fear. The build hangs on a 2014 bash script, production runs on a poorly documented physical server, nobody knows how to rebuild the environment.
A Symfony 7.1 codebase written six months ago can be legacy if the original team is gone, tests are missing and business logic is documented nowhere. Conversely, a PHP 5.6 codebase can be perfectly maintainable if it is covered by tests and documented.
Mapping across 5 axes
Before any decision, we map reality on five dimensions. The first four to eight weeks of a takeover are dedicated to this work. Skipping this step is the main mistake internal teams make when they try the takeover on their own.
1. Functional mapping
Understand what the code actually does for the business. Reading source code is not enough: you have to talk to humans and to production data.
- User interviews. Three to five users per business role. One hour each. Goal: reconstruct the real workflows, identify the critical journeys.
- Production log analysis. Last 30 days. Which URLs are hit the most? Which cron jobs actually run? Which API endpoints do external systems call?
- Business workshops. We rebuild the domain in context and container diagrams (C4 levels 1 and 2), data flows. Even when there is older documentation, it is typically wrong.
Deliverable: a C4 context and container diagram, plus an inventory of critical business journeys ranked by business impact.
2. Technical mapping
The full technical inventory. Everything that makes up the system, including what was forgotten long ago.
- PHP version actually running in prod, installed extensions.
- Main framework and version. Major libraries (ORM, templating, queue, cache).
- Composer:
composer outdated,composer why, detection of abandoned packages. - Database: version, size, tables, stored procedures, triggers.
- External services: APIs consumed, webhooks, SFTP, shared drives.
- Cron jobs, workers, queues.
- Infrastructure: where it runs, how it is deployed, who has access.
Tools to automate that inventory:
# Raw volumetry
phploc src/ --progress
# Complexity metrics
phpmetrics --report-html=metrics src/
# Class and package dependency graph
pdepend --summary-xml=pdepend.xml --dependency-xml=deps.xml src/
# Current coverage (if any tests exist)
phpunit --coverage-text --coverage-html=coverage/
# Dependency security audit
composer audit --format=json > audit.json
symfony security:check
3. Risk mapping
Risks are the list of things that can take the system or the company down. We rank them by probability and impact.
| Risk | Detection | Typical severity |
|---|---|---|
| Critical CVE on dependency | composer audit + Symfony Security Advisories |
High |
| PHP version out of support | EOL date on php.net | Medium to high |
| Secrets in source | gitleaks detect, targeted grep |
Critical |
| Concatenated SQL (injections) | PHPStan custom rules, manual review | Critical |
| Deprecated or removed functions | Rector PHP8x sets, reverse DowngradePhp80 |
Medium |
unserialize on untrusted input |
grep + review | Critical |
| XSS in templates | Twig/Blade audit, strict mode | High |
| Crons without monitoring | Ops mapping | Medium |
| Untested backups | IT interview | Critical |
Our rule: any critical-severity row must have a remediation planned within 30 days, even if the rest of the plan spans eighteen months.
4. Debt mapping
Measure debt with tools, not gut feel. The baseline trio:
- PHPStan level max. We run level 9 (strict), we count the errors, we generate a baseline. That baseline becomes the debt counter.
- Rector dry-run. With sets
PHP81,SYMFONY_64,CODE_QUALITY,DEAD_CODE. The number of proposed transformations is a good proxy for modernization debt. - Deptrac. To measure layer violations. The more violations, the more painful the rewrite will be.
Example of a deptrac.yaml we install in the first week:
parameters:
paths:
- src
exclude_files:
- '#.*test.*#i'
layers:
- name: Controller
collectors:
- type: classLike
value: 'App\\Controller\\.*'
- name: Service
collectors:
- type: classLike
value: 'App\\Service\\.*'
- name: Domain
collectors:
- type: classLike
value: 'App\\Domain\\.*'
- name: Infrastructure
collectors:
- type: classLike
value: 'App\\Infrastructure\\.*'
- name: Legacy
collectors:
- type: classLike
value: 'Legacy\\.*'
ruleset:
Controller:
- Service
- Domain
Service:
- Domain
Domain: ~
Infrastructure:
- Domain
Legacy:
- Controller
- Service
- Domain
- Infrastructure
The Legacy layer is deliberately permissive. It materializes the old zone we wrap, then shrink over the months. Each modernization cycle must reduce the size of that layer, measured in class count and kloc.
5. Human mapping
The human factor kills the most migrations. Who knows what, who is about to leave, who is motivated by the work.
- Bus factor per module. How many people can maintain each functional block. A bus factor of 1 on a critical module is a red flag.
- Expected turnover. Departures in progress, long holidays, ending contracts.
- Tribal documentation. What people know that is written down nowhere. We capture that knowledge in recorded sessions, in Architecture Decision Records, in runbooks.
- Stance on the project. The sceptics, the movers, the resisters. A major change without a strong internal sponsor fails.
Concrete tools we impose
The first month of a takeover engagement is about installing and running this observation stack, without touching business code.
- PHPStan with a progressive level and a baseline. Wired into CI within the first week.
- Psalm as a complement for impossible-code detection and taint analysis.
- Deptrac for architectural discipline.
- Rector in dry-run to estimate achievable modernization.
- PHPMetrics for complexity visualizations, to show to the sponsor and steering committee.
- phploc for volumetry, compared month over month.
- PhpDepend for coupling and abstraction graphs.
- Composer audit and Symfony Security Advisories for CVE hygiene.
- Gitleaks to detect historical secrets in git, plus rotation of leaked credentials.
Decision matrix: keep, rewrite, wrap, kill
Once the mapping is in hand, every module enters one of four categories. We build this matrix with the business sponsor and the tech lead.
| Module | Business impact | Technical state | Decision | Rationale |
|---|---|---|---|---|
| Billing core | Critical | Messy but working | Keep + progressive refactor | Too risky to rewrite, high business value |
| Legacy CSV import | Low, monthly | Obsolete | Kill | Replace with a no-code workflow |
| Internal HR module | Medium | Very degraded | Wrap behind API, rewrite later | Isolate to stop touching it |
| Homegrown full-text search | Medium | Complex, poorly documented | Replace with managed Elasticsearch | Immediate ROI |
| Payment module | Critical | PCI compliance required | Rewrite cleanly | Compliance risk |
| Admin portal | Low | Barely used | Kill | Confirmed with business, usage < 5%/month |
The rule that ends the most debates: no rewrite without value proof. Rewriting costs 1 to 3 times the initial cost, and during the rewrite the legacy keeps moving. We only authorize a rewrite where wrapping is untenable (compliance, blocking debt, runaway run cost).
Quick wins achievable in one week
Even before the mapping is complete, some gains are immediate and reassure the sponsor. They show that the takeover is active.
composer audit --lockedand patching of critical CVEs via targeted upgrades.- Move to the latest supported PHP minor on the current branch (7.4 → 8.0 minimum, ideally 8.3).
- Install an error monitoring tool (Sentry or Bugsnag): within 48 hours we discover the silent errors nobody was seeing.
- Enable structured logs (Monolog JSON) and ship them to a centralized platform (OpenObserve, Elasticsearch, Loki).
- Set
display_errors = Offandexpose_php = Offin production if it is not the case (still seen in 2025). - Rotate git-leaked secrets.
- PHPStan level 1 in CI with baseline, to stop regression.
- Test a database backup restore end to end on a test environment.
Recommended 5-step exit plan
After mapping, we propose a structured plan that we adapt to each client.
- Stabilize (1 to 3 months). Critical CVEs patched, CI in place, active monitoring, characterization tests on critical journeys. Goal: stop the decay.
- Wrap (3 to 6 months). Introduce an anti-corruption layer, expose critical business functions behind clean APIs, tightened Deptrac. Goal: isolate the legacy so that work can happen around it.
- Modernize in slices (6 to 18 months). Strangler pattern module by module. Every quarter has a measured target: X% of the Legacy layer rewritten, Y kloc removed, Z endpoints migrated. Never a big bang.
- Decommission (12 to 24 months). Delete replaced modules, archive the old, cut dead dependencies. That step is the hardest psychologically: teams hesitate to remove code "just in case".
- Consolidate (continuously after 18 months). The platform sits on a modern base, debt is under control, tests cover the critical journeys. We move into standard evolution mode, with strict debt discipline.
Anonymised case: B2B platform, 400 kloc
To give a sense of what this method produces, here is a real anonymised case handled by our team.
- Context. B2B platform in regulated financial services. 400 kloc PHP. Zend Framework 1.12, Doctrine 1.2, PHP 7.2 in production. Zero automated tests. 3 developers, none present at the 2012 kickoff.
- Mapping (8 weeks). Inventory of 47 critical business journeys, 12 modules identified, keep/rewrite/wrap/kill matrix validated by the executive committee.
- Stabilization (4 months). PHP 7.4, Sentry monitoring, 18 critical CVEs patched, tested backup, PHPStan level 2 CI. Zero incidents during that phase.
- Wrapping (6 months). A new modern REST API exposed through a fresh Symfony 6.4 app, all critical writes routed through it. The legacy front keeps calling the database in read-only mode.
- Modernization (12 months). Strangler pattern per module: billing, reporting, workflows, admin. At the end, 60% of the Legacy codebase rewritten, PHP 8.3, Symfony 7.1.
- Results over 18 months. Zero major production regressions. Feature lead time divided by 4. Infrastructure cost reduced by 35% via server consolidation. Team grew from 3 to 7 developers, all trained on the new stack.
Conclusion
Taking over a legacy PHP codebase is neither a leap of faith nor a craft project. It is a method. Mapping on five axes, the decision matrix and the 5-step exit plan let us bring to a controlled risk what many view as an impossible rescue.
For a takeover audit or a long modernization engagement, email us at contact@your-digital-hub.com or discover our PHP expertise and migration services.