A while back I wrote about upgrading our codebase from PHP 7.3 to 8.4 by hand-picking Rector rules instead of using the level sets. The argument was that cherry-picking ~20 rules was the smaller risk than letting a version set rewrite hundreds of things at once.
I stand by that. But there’s a part two I didn’t see coming: a hand-picked config set up for a one-time migration doesn’t stay hand-picked. It becomes a thing that runs on every commit, and nobody revisits it, and a year later half of it is doing nothing at all.
I finally went back and read it. Here’s what was in there.
Rules that cannot match the code anymore
The migration that motivated the original config is over. The codebase is on PHP 8.4 now. But the config still listed the rules that did the upgrading:
->withRules([
Php4ConstructorRector::class,
ReplaceHttpServerVarsByServerRector::class,
EregToPregMatchRector::class,
// ...
])
Walk through what these can match on an 8.4 codebase:
Php4ConstructorRectorrewrites PHP 4-style constructors (a method named after its class) into__construct. A PHP 4 constructor on PHP 8 isn’t a deprecation — it’s a fatal error. If one existed, the code wouldn’t run; the rule would never get the chance.EregToPregMatchRectorconvertsereg()topreg_match().ereg()was removed in PHP 7. There is nothing for it to find.ReplaceHttpServerVarsByServerRectorreplaces$HTTP_SERVER_VARSwith$_SERVER. That superglobal has been gone since PHP 5.4.
None of these are wrong, exactly. They’re just rules whose entire job was to get you to a version you’re already past. Keeping them is like keeping a “convert VHS to DVD” step in a pipeline that only handles streaming now. They cost a little time on every run and, more importantly, they make the config lie about what it’s for.
Custom rules that nothing references
The config also imported a set of project-specific rules we’d written for the migration:
use App\Rector\ArrayPointerRector;
use App\Rector\ArraySortRector;
use App\Rector\ConvertEnvToGetenvRector;
use App\Rector\NewArgumentTypeCastRector;
use App\Rector\TypecastArgsRector;
NewArgumentTypeCastRector was configured with a single hard-coded target — one class, one constructor signature — clearly a surgical fix for one spot during the upgrade:
->withConfiguredRule(NewArgumentTypeCastRector::class, [
'Meta' => ['string', 'string', 'int'],
])
Two of these (ArrayPointerRector, ArraySortRector) were imported at the top but never actually added to withRules() — dead use statements feeding nothing. The rest were one-time transforms whose job was done. None of them are referenced by application code; they exist only to be wired into this config, and the config no longer needs them. So they all came out, custom rule classes and imports together.
A small bonus gotcha: a rule that got renamed under me
While I was in there, Rector failed to load on a class name:
use Rector\CodingStyle\Rector\String_\SymplifyQuoteEscapeRector; // old
use Rector\CodingStyle\Rector\String_\SimplifyQuoteEscapeRector; // current
Symplify → Simplify. Upstream had corrected the typo in the rule’s own name in a newer Rector release, and our pinned config still referenced the misspelled one. Nothing dramatic — but a reminder that a tooling config isn’t a write-once artifact. It has a dependency that moves, and the config rots against it whether you look or not.
What I replaced it with
The cleanup wasn’t only subtraction. The config is now explicitly scoped to ongoing maintenance — style rules, the 7.4–8.2 deprecation rules that still catch real things as people write new code, plus a few forward-looking modernizations the codebase can actually use:
// PHP 7.3 / 8.0 modernizations
ArrayKeyFirstLastRector::class,
IsCountableRector::class,
StrContainsRector::class,
StrStartsWithRector::class,
StrEndsWithRector::class,
GetDebugTypeRector::class,
// PHP 8.4 / 8.5 modernizations
NewMethodCallWithoutParenthesesRector::class,
ExplicitNullableParamTypeRector::class,
ColonAfterSwitchCaseRector::class,
The str_contains / str_starts_with / str_ends_with rules alone modernize 60-odd real strpos/substr comparisons across the codebase — actual readability wins on code we keep touching. That’s the difference: these rules describe how I want new and existing code to look, indefinitely. The ones I removed described a destination we’d already reached.
I also moved Rector into require-dev (rector/rector ^2.3) so composer rector resolves the project-pinned binary instead of relying on whoever happened to have it installed globally. If a tool runs in CI, it should be a declared dependency, not folklore.
The takeaway
Dead application code announces itself — coverage tools flag it, a grep for the function name comes up empty, someone deletes it in a cleanup PR. Dead tooling config hides, because the tool keeps running successfully. A rule that can’t match isn’t an error; it’s a no-op. Everything stays green, so nobody looks.
The honest test for a maintenance config isn’t “does it pass.” It’s: for each rule, what code does this match today, and what would I do if it ever did? If the answer is “nothing can match it” or “I don’t remember why this is here,” it’s not protecting you. It’s just running.
A migration config and a maintenance config are two different files that happen to live at the same path. The day the migration finishes, somebody has to notice that and rewrite the file for its new job. This time it was a year late. Now it’s on the list to actually re-read once a year, not just whenever it breaks.