Rector is famous for the dramatic refactors. Add return types across the whole codebase. Convert every closure to an arrow function. Promote constructor properties. Sprinkle readonly over your value objects. The before/after PRs that go viral.
We used Rector to upgrade a PHP 7.3 codebase to 8.4. Our config applies none of that. Every rule we picked is a mechanical, semantics-preserving transform that either fixes a forward-compatibility break or removes a syntax form PHP no longer supports. That was the deliberate choice. The boring rules are the ones that get a legacy codebase to 8.4 without setting it on fire.
This is what our rector.php looks like, and why.
The codebase, briefly
- Multiple repos sharing a parent directory. One
rector.phpreaches into all of them. .incfiles (legacy PEAR-ish convention) alongside.php. Templates in.tplfor a custom template engine.- Thousands of classes. Some still using PHP 4 constructors (
function ClassName()instead of__construct). - Customer-specific subdirectories that get pulled in dynamically.
- Almost no type declarations in legacy code. A grep for
: voidreturns dozens of matches across millions of lines.
The conventional Rector advice for this kind of codebase is “run UP_TO_PHP_84 and fix the breakages.” That’s the advice you give when you don’t have to live with the result.
What the level sets would have done
LevelSetList::UP_TO_PHP_84 bundles hundreds of rules. Among them:
- Add return type declarations everywhere Rector can infer them.
- Convert closures to arrow functions where possible.
- Promote constructor properties.
- Add
declare(strict_types=1)to every file. - Replace
nulldefaults with nullable parameter types. - Convert
array_mapcallbacks intofn() =>form.
Every one of those is fine on a codebase you understand fully. Every one of those is a footgun on a codebase the size of ours, because:
- Inferred return types are wrong when a legacy function “returns
string|false” but every caller treatsfalseas the empty case. Rector adds: stringbased on the dominant path, and callers blow up six months later when an edge case hits thefalsebranch through a return type now declared asstring. - Adding
strict_typeschanges how implicit type coercion works throughout each file. Every'5'that gets passed to anintparameter now throws. The test suite passes if the suite happens to use real ints there; production breaks where the callers passed strings. - Promoted constructors are great until you have inheritance and an existing parent signature you don’t want to change. The cleanup PR is bigger than the original Rector PR.
The unifying problem: those rules change semantics, or at least change what kind of bug a callsite produces. On a codebase nobody fully understands, that’s a lot of variance.
The rules we did pick share a single property: a passing test suite before the rule means a passing test suite after.
What we actually applied
Roughly twenty rules, grouped by what they fix:
PHP 5.3 / 7.0 forward-compat:
Php4ConstructorRector— rename PHP 4-style constructors to__construct. PHP 8 dropped support.ReplaceHttpServerVarsByServerRector—$HTTP_GET_VARS→$_GET. Long gone.EregToPregMatchRector—ereg()was removed in PHP 7.MultiDirnameRector—dirname(dirname($x))→dirname($x, 2). Trivial, mechanical.
PHP 7.4 forward-compat:
MbStrrposEncodingArgumentPositionRector— the encoding-arg position changed in 7.4.RestoreDefaultNullToNullableTypePropertyRector— property defaults that arenullneed an explicit nullable type after typed-properties land.
PHP 8.1 / 8.2 forward-compat:
NullToStrictStringFuncCallArgRector—strlen(null)is deprecated; rewrite tostrlen((string) $x).VariableInStringInterpolationFixerRector— fixes"${var}"→"{$var}". The bare${}form is deprecated.Utf8DecodeEncodeToMbConvertEncodingRector—utf8_decode()/utf8_encode()are deprecated.
Style (no semantic change):
SymplifyQuoteEscapeRector,WrapEncapsedVariableInCurlyBracesRector— string-form normalization.SeparateMultiUseImportsRector,SplitGroupedPropertiesRector,SplitGroupedClassConstantsRector— one declaration per line.ConsistentImplodeRector—implode($sep, $arr)argument order normalization.
Dead-code:
RecastingRemovalRector—(int)(int) $x→(int) $xand similar redundant casts. Safe by definition.
That’s the entire active list. Twenty-ish rules, every one of them either removes a deprecation or normalizes a syntax form. Not one of them tries to infer types, restructure control flow, or modernize an idiom.
The five custom rules we wrote
When Rector didn’t ship a rule for a pattern we kept hitting, we wrote one. Each is ~100 lines of NodeVisitor code in our app/Rector/ namespace, autoloaded the same way Rector’s built-ins are.
ArrayPointerRector—each()was deprecated in 7.2 and removed in 8.0. There are still patterns likewhile (list($k, $v) = each($arr))in code older than the test suite. This rule rewrites them toforeach.ArraySortRector— PHP 8 made sort algorithms stable. Callers that relied on the previous unstable order can produce surprising results after the upgrade. The rule rewrites a known set ofuksortcallsites to make the new ordering explicit.NewArgumentTypeCastRector— declarative per-class argument casting at the callsite. We pass it'Meta' => ['string', 'string', 'int']and it casts everynew Meta(...)accordingly. Much safer than adding constructor types and breaking 500 callsites at once; you tighten the boundary at the call end first, then the constructor catches up later.ConvertEnvToGetenvRector— replaces calls to a project-localenv()helper withgetenv(). We had two competing implementations ofenv(). Migrating callsites togetenv()consolidated the surface area before we deleted them.TypecastArgsRector— applies known casts to known function arguments based on a config table. PHP 8.1+ deprecates passingnullto non-nullable internal function params; the built-inNullToStrictStringFuncCallArgRectorcatches some, this fills the gap for our own functions.
The pattern across all five: target one specific deprecation or anti-pattern, do nothing else. None of them try to be a refactoring tool.
Two non-obvious config bits
->withFileExtensions(['inc', 'tpl', 'php'])
Rector defaults to .php only. Our .inc files (legacy class.inc convention) and the .tpl templates contain PHP and execute at runtime, so they have to be in scope or the upgrade is incomplete.
Then there’s a 25-line filesystem walker that identifies files containing PHP 4-style constructors (function ClassName(...) inside a class ClassName). The reason: Php4ConstructorRector operates per-file, and our directory tree is large enough that handing it only the files it could possibly transform cut a Rector run from minutes to seconds. The walker is uglier than it needs to be — it’s the kind of code where “good enough on first try, never looked at again” applied.
What we left for later
UP_TO_PHP_84 and its more aggressive cousins are still right there. Once the codebase has typed properties everywhere — once the next round of modernization makes inference reliable — we can add an inference-based rule. That decision happens rule-by-rule, not as a set. The advantage of the cherry-pick is that adding the twenty-first rule is the same shape of decision as adding the first.
If your codebase looks anything like ours — .inc files, PHP 4 constructors, untyped legacy — the question is not “should I run Rector.” The question is which rules are safe enough to run today. That list is shorter than the level sets, and it’s the right place to start.