Rector for Legacy PHP: Automating 80% of the Upgrade Work
Ask a room of PHP developers whether they know Rector and most hands go up. Ask who has actually run it against a production legacy codebase and the room goes quiet. Rector PHP automated refactoring is the most underused capability in the ecosystem: a tool that can rewrite thousands of files correctly in minutes sits idle while teams upgrade method signatures by hand.
The hesitation is understandable. Legacy projects are fragile, Rector's output can be overwhelming on first contact, and nobody wants to review a 4,000-file diff. But the fear mostly comes from running Rector the wrong way: all rules at once, no baseline, no review workflow. Run it in the right order with the right guardrails and it reliably automates the bulk of a version or framework upgrade, leaving your team to focus on the 20% that needs human judgment.
This post walks through the workflow we use in legacy modernization projects: installing Rector in an old codebase without dependency conflicts, sequencing rule sets, writing custom rules for project-specific patterns, reviewing changes with confidence, and what the results actually look like on a real Symfony 3.4 application.
Installing Rector in a Symfony 2-Era Project Without Dependency Hell
The first obstacle is real: Rector requires PHP 7.4+ and modern dependencies, while your legacy project pins ancient versions of everything. Running composer require rector/rector --dev inside a Symfony 2 or 3 project usually ends in a wall of version conflicts.
The fix is to not install Rector into the project at all. You have two clean options.
Option 1: a separate tools directory. Keep Rector in its own composer.json, isolated from the application:
mkdir -p tools/rector
composer require --working-dir=tools/rector rector/rector
tools/rector/vendor/bin/rector process src --dry-run
Rector parses your code statically. It does not need to execute it, so it does not need to share your application's dependency tree. It only needs your code and, ideally, your autoloader for type resolution.
Option 2: the PHAR or Docker image. The official Docker image gives you a fully isolated runtime, which also solves the problem of Rector needing a newer PHP version than the one your legacy app runs on:
docker run --rm -v $(pwd):/project rector/rector:latest process /project/src --dry-run
One configuration detail matters for legacy projects: point Rector at your autoloader explicitly with autoload_paths (or bootstrapFiles for odd setups) so it can resolve types in code that predates PSR-4. Without type information, Rector skips rules that would otherwise apply, and you silently lose coverage.
Rule Sets: The Order Matters More Than the Selection
The most common way teams sabotage their first Rector run is applying everything at once: PHP upgrade rules, Symfony rules, dead code removal, and coding style in a single pass. The diff becomes unreviewable and the run gets abandoned.
Sequence the work instead. Each phase gets its own run, its own review, and its own commit:
- PHP version rules first. Work through
LevelSetListtargets one version at a time: 7.1, 7.4, 8.1. Language-level changes (arrow functions, null coalescing, constructor promotion, readonly properties) are independent of your framework and give you the platform every later rule depends on. - Framework rules second. Symfony rule sets assume modern PHP syntax. Running
SymfonySetListupgrades on PHP 5-era code produces noisy, sometimes wrong output. On post-upgrade code they are precise: converting deprecated controller patterns, updating event dispatcher signatures, migrating away from removed base classes. - Type declaration rules third. The
TypeDeclarationLevelrules infer parameter and return types from usage. They work dramatically better after the version upgrades because they can build on modern syntax and on types the framework rules already introduced. - Dead code and code quality last. These are cleanups, not upgrades. Keeping them separate keeps every earlier diff focused on one question: did the behavior stay the same?
A minimal rector.php for the first phase looks like this:
use Rector\Config\RectorConfig;
use Rector\Set\ValueObject\LevelSetList;
return RectorConfig::configure()
->withPaths([__DIR__ . '/src'])
->withSets([LevelSetList::UP_TO_PHP_74]);
Resist the temptation to add more sets "while we're at it". One concern per run is what keeps the review honest.
Custom Rules for the Patterns Only Your Codebase Has
Every legacy codebase accumulates house patterns: a homegrown ArrayHelper::get() that predates the null coalescing operator, static service locator calls like AppKernel::getContainer()->get('mailer'), or a custom collection class that duplicates what array_map does. No public rule set knows about them, but they often appear thousands of times.
This is where Rector pays off most, because a custom rule is a few dozen lines of PHP. A rule that replaces ArrayHelper::get($data, 'key', null) with $data['key'] ?? null follows a simple shape: match a static call node, check the class and method name, return the replacement node. The Rector documentation covers the node API, and vendor/bin/rector custom-rule scaffolds the boilerplate and a test.
Two guidelines from projects where this went well:
- Write a test fixture first. Rector rules are tested with before/after code snippets. Collect five real examples from your codebase, including the weird ones, and make them the fixture. The weird ones are where handwritten rules break.
- Prefer narrow rules over clever ones. A rule that handles 90% of occurrences safely and skips the rest beats one that handles 100% and rewrites three of them incorrectly. Anything the rule skips shows up in a later grep and gets fixed by hand.
For deprecated service container access specifically, write the rule to convert to constructor injection only when the class is already a registered service, and to simply report the location otherwise. Semi-automated beats wrongly automated.
The Dry-Run and Diff Review Workflow
Confidence in thousands of automated changes does not come from trusting Rector. It comes from a review process that would catch Rector being wrong:
vendor/bin/rector process src --dry-run
Then, per phase:
- Run with
--dry-runand skim the summary. Rector lists every applied rule with a diff. You are not reviewing line by line yet; you are checking that the rules applied are the rules you expected. - Apply one rule set, commit, and diff by rule. A commit that contains exactly one kind of change is reviewable at speed. Fifty files that all convert
array()to[]take two minutes to approve. The same files mixed with type changes take an hour. - Run the test suite after every phase. If your legacy project lacks tests, this is the moment to add characterization tests around the critical paths first. Static analysis helps too: a PHPStan baseline run before and after each phase catches type regressions that tests miss.
- Spot-check the risky rules by hand. Type inference rules and anything touching conditionals deserve a real look. Syntax conversions do not.
Teams that follow this loop merge Rector changes daily. Teams that run everything at once produce a heroic branch that dies in review.
Before and After: A 40K-Line Symfony 3.4 Codebase
The honest numbers from a recent engagement: a 40,000-line Symfony 3.4 application on PHP 7.1, target Symfony 6.4 on PHP 8.2, part of a larger step-by-step modernization.
What Rector handled essentially perfectly:
- Language syntax upgrades across every file: short arrays, arrow functions, null coalescing, string functions, constructor promotion. Zero regressions.
- Around 2,300 added parameter and return type declarations, of which roughly 40 needed manual correction, mostly where legacy code genuinely returned mixed types.
- Controller and command signature migrations for the framework upgrade, including the move to attribute-based routing.
- Our three custom rules eliminated about 1,900 calls to house helpers and the static container accessor.
What Rector missed entirely, and always will:
- Architectural decisions. Rector converts a deprecated API call to its replacement. It cannot decide that a 900-line controller should be five services.
- Behavioral ambiguity. Where loose comparisons and implicit casts hid bugs, stricter code surfaces them. A human has to decide which behavior was intended.
- Templates, configuration, and the database layer. Twig deprecations, YAML service definitions, and Doctrine mapping changes needed separate tooling or hands.
The bottom line: about 80% of the mechanical upgrade work was automated, and the calendar effect was larger than the percentage suggests. The team spent its time on the interesting 20%, which is exactly the part where experience matters and where a rewrite-versus-refactor decision sometimes gets revisited for individual modules.
Where to Start on Monday
Pick your smallest bounded context. Install Rector isolated in tools/, run LevelSetList::UP_TO_PHP_72 (or whatever your next minor step is) with --dry-run, and read the diff over coffee. That single run tells you more about the effort of your upgrade than any estimation meeting.
If you would rather have someone who has done this a few dozen times sequence the rule sets, write the custom rules, and keep the review load sane, that is exactly the work of our code quality consulting and legacy modernization services. Write to hello@wolf-tech.io or take a look around wolf-tech.io to see how we approach it.

