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.php reaches into all of them.
  • .inc files (legacy PEAR-ish convention) alongside .php. Templates in .tpl for 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 : void returns 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 null defaults with nullable parameter types.
  • Convert array_map callbacks into fn() => 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 treats false as the empty case. Rector adds : string based on the dominant path, and callers blow up six months later when an edge case hits the false branch through a return type now declared as string.
  • Adding strict_types changes how implicit type coercion works throughout each file. Every '5' that gets passed to an int parameter 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.
  • EregToPregMatchRectorereg() was removed in PHP 7.
  • MultiDirnameRectordirname(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 are null need an explicit nullable type after typed-properties land.

PHP 8.1 / 8.2 forward-compat:

  • NullToStrictStringFuncCallArgRectorstrlen(null) is deprecated; rewrite to strlen((string) $x).
  • VariableInStringInterpolationFixerRector — fixes "${var}""{$var}". The bare ${} form is deprecated.
  • Utf8DecodeEncodeToMbConvertEncodingRectorutf8_decode() / utf8_encode() are deprecated.

Style (no semantic change):

  • SymplifyQuoteEscapeRector, WrapEncapsedVariableInCurlyBracesRector — string-form normalization.
  • SeparateMultiUseImportsRector, SplitGroupedPropertiesRector, SplitGroupedClassConstantsRector — one declaration per line.
  • ConsistentImplodeRectorimplode($sep, $arr) argument order normalization.

Dead-code:

  • RecastingRemovalRector(int)(int) $x(int) $x and 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.

  1. ArrayPointerRectoreach() was deprecated in 7.2 and removed in 8.0. There are still patterns like while (list($k, $v) = each($arr)) in code older than the test suite. This rule rewrites them to foreach.
  2. 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 of uksort callsites to make the new ordering explicit.
  3. NewArgumentTypeCastRector — declarative per-class argument casting at the callsite. We pass it 'Meta' => ['string', 'string', 'int'] and it casts every new 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.
  4. ConvertEnvToGetenvRector — replaces calls to a project-local env() helper with getenv(). We had two competing implementations of env(). Migrating callsites to getenv() consolidated the surface area before we deleted them.
  5. TypecastArgsRector — applies known casts to known function arguments based on a config table. PHP 8.1+ deprecates passing null to non-nullable internal function params; the built-in NullToStrictStringFuncCallArgRector catches 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.