Legacy PHP Refactoring: The Step-by-Step Process for Modernising a Symfony 2/3 Codebase Without a Rewrite
The Symfony 2.8 application your team inherited six years ago was probably well-engineered for its time. But it now runs on PHP 7.2, has no type declarations, uses the pre-Flex bundle system, and your best developers spend more time fighting deprecation warnings than shipping features.
You know it needs to change. The question is how — without halting the business for twelve months and gambling everything on a rewrite that may never fully ship.
Legacy PHP refactoring, done in phases, is the answer. Not a rewrite. Not a freeze. A disciplined, incremental modernisation that keeps the application in production throughout.
This is the five-phase process Wolf-Tech uses when clients bring us Symfony 2 and Symfony 3 codebases that need to reach Symfony 7 and PHP 8.3.
Why Rewrites Almost Always Fail
Before the process: a word on the rewrite trap.
Joel Spolsky wrote about this in 2000 and nothing has changed. A full rewrite discards the accumulated knowledge baked into every ugly conditional in your codebase. That if ($user->getCountry() === 'DE' && $cart->hasTaxExemption()) branch exists because a German tax auditor appeared in 2017 and the edge case took three sprints to solve correctly. A greenfield rewrite will reproduce that bug.
Rewrites also have a nasty property: they take twice as long as estimated, deliver 70% of the features, and require the old system to keep running in parallel — which means you are maintaining two codebases simultaneously.
The alternative is legacy PHP refactoring: move the existing code forward, one well-tested step at a time.
Phase 1: Baseline Measurement
You cannot improve what you have not measured. Before touching a single line of application logic, establish a baseline that will guide every subsequent decision.
PHPStan at level 0. Install PHPStan and run it at level 0 to get a raw count of type-related violations. For a typical Symfony 2.8 codebase of 60–80K lines, expect 1,500–5,000 errors. Do not panic. Write the number down. This is your starting point.
Test coverage report. Run your test suite — if one exists — and generate an HTML coverage report with Xdebug or PCOV. For most codebases in this state, coverage sits between 15% and 35%. Focus less on the percentage and more on which directories are untested. Controllers and domain services with zero coverage are your highest-risk refactoring targets.
Cyclomatic complexity hotspot map. Use PHP Mess Detector (phpmd) or the PHP_CodeSniffer complexity sniff to identify functions with cyclomatic complexity above 15. These are your highest-risk files. Print the top 20 — they will demand special handling later.
Rector upgrade estimator. Run rector process src --dry-run --config rector-upgrade-estimate.php with a config targeting your destination PHP and Symfony versions. Count the number of changes Rector reports it can automate. Subtract this from total violations to estimate the scope of manual work.
This baseline takes half a day on a mid-size codebase and will save weeks of misdirected effort later.
Phase 2: Plan the Symfony Upgrade Path
Jumping from Symfony 2.8 directly to Symfony 7 in a single step is technically possible and practically catastrophic. The correct approach is a multi-version migration path that follows the long-term support (LTS) releases.
The recommended path is: 2.8 → 3.4 LTS → 4.4 LTS → 5.4 LTS → 6.4 LTS → 7.2.
Each LTS-to-LTS hop is sized to fit inside a single sprint or a two-week work package. This matters because each hop has a predictable deprecation surface — Symfony's UPGRADE notes document exactly what changed.
Composer and Flex migration. Symfony 2 and 3 use the pre-Flex bundle registration pattern: AppKernel.php manually registers every bundle. Starting from Symfony 4, Symfony Flex automates bundle wiring through recipes. The migration from manual AppKernel.php to Flex is one of the more time-consuming steps but pays dividends immediately in reduced configuration boilerplate. Complete it during the 3.4 → 4.4 hop.
PHP compatibility matrix. Each Symfony version has minimum PHP requirements. Symfony 5.4 requires PHP 7.4+. Symfony 6.4 requires PHP 8.1+. Symfony 7.x requires PHP 8.2+. This means the PHP upgrade must happen in parallel with the Symfony upgrade — you cannot do Symfony alone. Budget for at least two PHP major version bumps if you are starting from PHP 7.2.
Deprecation resolution priority. During each hop, symfony/deprecation-contracts will emit deprecation notices for API usage that will be removed in the next major version. Use SYMFONY_DEPRECATIONS_HELPER=max[self]=0 in your test suite to treat self-deprecations as test failures. This converts a passive warning stream into an active gate that prevents you from moving forward until deprecations are cleared.
Consider using a dedicated legacy code modernisation service if the upgrade surface is larger than two sprints — the cost of getting the version sequencing wrong compounds at every subsequent hop.
Phase 3: Test Before You Refactor
This is the phase most teams skip, and it is why most legacy PHP refactoring projects fail halfway through.
The core rule: do not refactor code you do not have a characterisation test for.
A characterisation test does not test what the code should do. It tests what it currently does — bugs and all. Write one for every class you plan to modify before you modify it. If the tests pass before and after your change, you have not broken any existing behaviour.
Approval testing for untestable code. For methods where writing unit tests is impractical — legacy controllers with seven dependencies, God classes with 2,000 lines — use approval testing. Approval testing captures the full output of a method (HTML, JSON, serialised object) into a "received" file and compares it against an "approved" baseline. It is not elegant, but it gives you a safety net that costs 30 minutes to set up.
The Strangler Fig approach for untestable classes. For classes where even approval testing is impractical — typically classes with hard-coded database calls, global variables, or unresolvable static dependencies — apply the Strangler Fig pattern. Create a new implementation alongside the old one, route a small percentage of traffic to the new code, verify correctness with comparison logging, and expand the routing incrementally until the old class can be deleted. This is how Wolf-Tech approaches legacy code modernisation: incrementally, with verification at each step, without stopping the business.
Phase 4: Automated Refactoring with Rector
Once you have tests covering the classes you intend to change, Rector does the heavy lifting.
Rector is an AST-based PHP refactoring tool. It parses your source code into a syntax tree, applies transformation rules, and writes back syntactically correct PHP. It does not guess. It applies deterministic rules.
The rules that cover 80% of mechanical changes. For a Symfony 2/3 to PHP 8 migration, these are the highest-value Rector rules to enable:
TypedPropertyRector— adds typed property declarations from PHPDoc annotationsAddArrayReturnDocTypeRector— infers return types for array-returning methodsStringClassNameToClassConstantRector— replaces'App\Entity\User'strings withUser::classAnnotationToAttributeRector— converts Doctrine and Symfony annotations (@ORM\Column) to PHP 8 attributes (#[ORM\Column])ClassPropertyAssignToConstructorPromotionRector— converts constructor property assignment to constructor promotionNullToStrictStringFuncCallArgRector— fixes null-coercion in string functions deprecated in PHP 8.1ReturnTypeFromStrictTypedCallRector— infers return types from method calls to strictly-typed methodsRemoveUselessParamTagRectorandRemoveUselessReturnTagRector— cleans up redundant PHPDoc
Run Rector on one directory at a time, review the diff, run your test suite, commit. Treat each Rector pass as a separate commit with a clear message (refactor: apply Rector typed properties to Domain/). This keeps the git history bisectable.
Rules requiring human review. Not everything Rector can change should be applied blindly. Rules that modify business logic, change exception types, or reorganise constructor arguments need a human in the loop. Build a rector-human-review.php config that excludes these rules from your automated pass and documents why.
Phase 5: Cutover and Monitoring
The final phase — moving production traffic to the modernised codebase — is where legacy PHP refactoring projects most commonly falter. Do not deploy all at once.
Feature-flag-based parallel run. Before full cutover, run both the old code path and the new code path simultaneously in production for selected traffic. Route 5% of requests through the modernised service, compare outputs, and log discrepancies. This is identical in principle to a database migration dry-run. It exposes edge cases your characterisation tests did not cover.
Rollback tripwires. Define measurable rollback criteria before you deploy: error rate above X%, p99 latency above Y ms, payment failures above Z per minute. Encode these as alerts in your monitoring stack (Datadog, Grafana, or New Relic). If a tripwire fires within 24 hours of a cutover, roll back immediately without discussion. You can investigate safely with the old code running.
Post-launch monitoring dashboard. Build a lightweight dashboard tracking four signals for 30 days after each major hop: error rate by endpoint, slow query count, deprecation notice count (should drop to zero after each hop), and PHPStan level (should rise steadily). The PHPStan level is particularly useful as a visible progress indicator for non-technical stakeholders.
Real-World Example: 80K Lines, Symfony 2.8 to Symfony 7, Six Months
A Wolf-Tech client came to us with an 80,000-line SaaS platform on Symfony 2.8 and PHP 7.2. The product had been in production for nine years, processed millions in annual transactions, and had 18% test coverage. A previous team had attempted a full rewrite and stopped after four months with a half-built parallel system that was quietly abandoned.
Our approach:
- Month 1: Baseline measurement, PHPStan level 0 audit (3,247 errors), characterisation tests for the 40 highest-complexity classes, Rector dry-run analysis.
- Month 2: PHP 7.2 → 7.4 → 8.0 upgrade, Symfony 2.8 → 3.4 hop, Flex migration, deprecation clearing.
- Month 3: Symfony 3.4 → 4.4, annotation-to-attribute Rector pass across all Doctrine entities, constructor promotion applied across the service layer.
- Month 4: Symfony 4.4 → 5.4, PHP 8.0 → 8.1, named arguments introduced progressively, test coverage raised to 54%.
- Month 5: Symfony 5.4 → 6.4, PHP 8.1 → 8.2, fully typed properties, PHPStan raised to level 5.
- Month 6: Symfony 6.4 → 7.2, PHP 8.2 → 8.3, PHPStan level 7, final parallel-run cutover.
Result: the application now runs on current supported PHP and Symfony versions, the deployment pipeline dropped from 18 minutes to 6 minutes, and the team's velocity on new features increased because developers no longer burn sprint capacity on deprecation firefighting. The full codebase history was preserved — every edge case, every business rule, every German tax-audit conditional — intact.
When to Start
The answer is: sooner than feels comfortable.
Symfony 3.4 reached end-of-life in November 2021. Symfony 4.4 LTS reached end-of-life in November 2023. If your application is still on either of those versions, you are no longer receiving security patches. Every day without a modernisation plan is a growing liability.
Legacy PHP refactoring does not require a budget freeze or a product roadmap pause. The five-phase process described here runs alongside feature development, with each phase producing a stable, releasable state. The first phase — baseline measurement — takes one working day and produces a clear picture of what you are dealing with.
If you want a second opinion on your codebase's refactoring scope before committing to a plan, Wolf-Tech offers a structured code assessment that produces a prioritised modernisation roadmap. Reach out at hello@wolf-tech.io or visit wolf-tech.io to start the conversation.

