All articles
12 September 2026
8 min read

Rector in a legacy project

By Tim Rutte, Cloud & Software ArchitectTopicLegacy & Modernization

A blue stamp resting on a stack of paper; only the top sheet carries an impression.

Rector usually gets introduced the same way and usually fails for the same reason. Somebody reads that it modernizes PHP code automatically, sets it up, picks the largest rule set, runs it over the whole project and ends up looking at four thousand changed files.

The rest is predictable. Nobody can review that, the test suite is red, the cause sits somewhere inside four thousand files, and after two days everything gets thrown away. After that the team knows: "we tried Rector, it does not work for us."

The tool was never the problem. The scope was. This article describes how Rector actually helps in a grown project, which rule sets are safe and which are opinions, and where the boundary runs.

What it is, and what it is not

Rector reads PHP code as a tree, applies rules to it and writes it back. A rule is a very concrete thing: "replace this call with that one", "add this type declaration", "turn this loop into that form".

Both the strength and the limit follow from that. Rector is unbeatable at changes that are identical a thousand times over and whose correct form follows from the code alone. It is blind to anything requiring meaning: whether this method is still needed, whether this special case is an agreement with a customer, whether this name is still accurate.

That makes it the exact counterpart of a coding agent. The agent understands intent and is not deterministic; Rector understands no intent and produces the same result a hundred times out of a hundred runs. For a mechanical change in a thousand places the second property is the more valuable one: what is deterministic does not have to be checked in a thousand places individually.

One rule set, one directory, one pull request

The entry point that holds is the inverse of the usual one: as little at a time as possible.

<?php
// rector.php - the first run. Deliberately narrow.
return RectorConfig::configure()
    // One directory, not the project. Ideally one that has
    // tests and that somebody on the team knows.
    ->withPaths([__DIR__ . '/src/Order'])

    // One jump, not all of them. From the version the project
    // sits on to the next one.
    ->withPhpSets(php82: true)

    // Nothing else. No clean-up rules, no quality sets.
    // Those come later and one at a time.
    ->withImportNames(importShortClasses: false);

The first run is a preview, not a write, and even the number it prints is information:

# --dry-run writes nothing and shows every change as a diff.
vendor/bin/rector process --dry-run

# Then: write, test, read, one pull request. In that order,
# and nothing else in the same pull request.
vendor/bin/rector process
vendor/bin/phpunit
git switch -c rector/php82-order

That last point is the most important and the most often violated: a Rector pull request contains only what Rector wrote. No hand edits, no "while I was in there anyway". The entire benefit for review rests on the reviewer knowing that no human decided anything here.

Mechanics and opinion

The rule sets fall into two groups, and they get treated very differently.

The language sets reproduce what a PHP version requires or permits. What they do is described in the language manual, and the result is largely unambiguous. These sets are why a jump across two major versions no longer takes weeks.

The quality and clean-up sets are something else. They are opinions about good code, and some of them change behaviour in edge cases. A loose equality check becomes a strict one, and the one call in the project that relied on the coercion now behaves differently. Removing an assignment whose value nobody reads is right, except when the assignment called a method with a side effect.

The rule for this is simple: language sets in a block, quality sets one at a time and only where tests pin the behaviour down. Have both in one run and you cannot say, when something breaks, whether the jump or an opinion caused it.

Without a net, mechanics only

Which states the precondition already, and it is the same as for any automated change to unfamiliar code: you need a way to establish whether behaviour changed.

Its absence does not rule Rector out. It means only the sets whose changes the language itself enforces are allowed, and that everything making a decision has to wait. There is a pleasant side effect going the other way: characterization tests for the area coming up next are the right first piece of work even if you never use Rector at all.

What belongs in every case is a static analyser. Rector changes, PHPStan says what no longer fits afterwards, and it does so before the first test run. The two tools do not replace each other, they complement each other in exactly that order.

The static analyser that says after every run what no longer fits is covered in Introducing PHPStan into legacy code step by step.

How to review four hundred files

Even with a narrow scope you get a pull request with a few hundred changed files, and the usual review is the wrong instrument for that.

The shift that solves it is small: review per rule, not per file. For every rule that was active in the run, pick three places, one of them unusual, and read them closely. If the rule is right in three places it is right in three hundred, because it is deterministic.

In practice that means --dry-run with one rule at a time rather than all of them, and the list of rules in the description of the pull request. The reviewer then reads a list of twelve rules and three examples each instead of four hundred files, and both together take half an hour.

The rule that does not exist yet

The point at which Rector turns from an upgrade tool into something else usually arrives after the second or third run, and it often gets missed: the interesting patterns in a grown project are specific to that project, and for those you write the rule yourself.

The trigger is always the same kind of task. A helper class of your own should be replaced by the language function that exists by now. A call should get one additional argument everywhere. A spelling the team agreed on should be enforced in eight hundred places. All of that is a week by hand and an afternoon as a rule.

final class ReplaceOwnTrim extends AbstractRector
{
    public function getNodeTypes(): array
    {
        return [StaticCall::class];
    }

    public function refactor(Node $node): ?Node
    {
        // Only our own helper, nothing else. A rule that matches
        // too broadly is worse than no rule.
        if (!$this->isName($node->class, 'App\Util\Text')) {
            return null;
        }
        if (!$this->isName($node->name, 'trimmed')) {
            return null;
        }

        // null means: nothing changed. That is the normal case
        // and has to be the cheapest path.
        return new FuncCall(new Name('trim'), $node->args);
    }
}

Two things in there are the actual work, not the ten lines. The rule has to match narrowly: matching a name without checking the class catches foreign calls that happen to share it. And it needs a test with a before and an after, run by the tool itself; without one the rule is a guess applied to eight hundred places.

Where the manual remainder of a framework jump begins is shown for Laravel in Laravel upgrades.

What Rector does not touch

Knowing the boundary saves the disappointment after the first good experience. Rector sees PHP, and a substantial part of a grown project is not PHP.

Untouched: configuration files, templates with a syntax of their own, SQL inside strings, JavaScript, dependencies in the package file, and everything produced through dynamic calls. A call assembled from a string is invisible to a tool that reads the tree.

And one level up: a framework migration in which the meaning of a call changes and not just its name is not a matter of rules. There are migration packages that take part of it off your hands, and what remains is manual work. Know that in advance and you plan the jump correctly; find out afterwards and you conclude the tool is unreliable.

The same work shown for a framework is covered in Working through Symfony deprecations without drowning in the log.

From a one-off run to a check

The step that turns a clean-up into a state costs ten minutes and rarely gets taken: Rector stays in the project and runs in the build with --dry-run. If it has anything to say, the pull request is red.

That ends the drift back. New code is written from now on in the form the project was just brought into, and the next jump to a new language version does not start at four thousand files again but at the few hundred the new version touches.

Together that is the whole answer to why the first attempt did not work: not the tool, the amount per step. How a PHP upgrade runs overall is described on its own page.

This article belongs to a series about systems that already exist. The retrospective orders every article in it by situation.