DEV Community

Cover image for Rector in 2026: The 6 Rules That Pay for Themselves on Any Legacy Codebase
Gabriel Anhaia
Gabriel Anhaia

Posted on

Rector in 2026: The 6 Rules That Pay for Themselves on Any Legacy Codebase


A team I talked to last month ran Rector on a 12-year-old Laravel app with the LevelSetList::UP_TO_PHP_82 preset. The diff was 14,000 lines. Half the test suite went red. They reverted the branch and never opened Rector again.

That's the wrong way to use it. Rector isn't a button. It's a kit of 600+ rules, and most of them are sharp.

Six of those rules are different. They're safe, deterministic, and they pay for themselves the first time you run them on a real codebase. The rest you should treat the way you'd treat sed -i on production data: useful when you know exactly what you're doing, dangerous otherwise.

This post is about those six. Why they're safe, what they do, and how to wire them into CI without turning your PR review into a wall of churn.

What Rector actually is

Rector parses your PHP into an AST using nikic/php-parser, runs a set of node visitors over it, and writes the result back. It's not regex. It's not a linter. It rewrites your code based on what the code means, not what it looks like.

That's why it can do things like "find every property that's only assigned in the constructor and add a type declaration". A linter can't do that. Rector can.

Install it as a dev dependency:

composer require rector/rector --dev
Enter fullscreen mode Exit fullscreen mode

Version 1.x ships with the 600+ rules. The six below are all in core. No extra plugin needed.

The config file you'll start from

Skip the level sets. Skip the "PHP 8.2 upgrade" preset. Start with this rector.php at the repo root:

<?php

declare(strict_types=1);

use Rector\Config\RectorConfig;
use Rector\TypeDeclaration\Rector\Property\TypedPropertyFromAssignsRector;
use Rector\CodeQuality\Rector\FuncCall\ChangeArrayPushToArrayAssignRector;
use Rector\TypeDeclaration\Rector\ClassMethod\ReturnTypeFromStrictTypedCallRector;
use Rector\Privatization\Rector\Property\ReadOnlyPropertyRector;
use Rector\DeadCode\Rector\MethodCall\RemoveExtraParametersRector;
use Rector\TypeDeclaration\Rector\ClassMethod\ParamTypeFromStrictTypedPropertyRector;

return RectorConfig::configure()
    ->withPaths([
        __DIR__ . '/app',
        __DIR__ . '/src',
        __DIR__ . '/tests',
    ])
    ->withSkip([
        __DIR__ . '/app/Legacy',     // skip the no-go zones
        __DIR__ . '/database/migrations',
    ])
    ->withPhpVersion(\Rector\ValueObject\PhpVersion::PHP_82)
    ->withRules([
        TypedPropertyFromAssignsRector::class,
        ChangeArrayPushToArrayAssignRector::class,
        ReturnTypeFromStrictTypedCallRector::class,
        ReadOnlyPropertyRector::class,
        RemoveExtraParametersRector::class,
        ParamTypeFromStrictTypedPropertyRector::class,
    ]);
Enter fullscreen mode Exit fullscreen mode

Six rules. That's the whole config. Run it with:

vendor/bin/rector process --dry-run
Enter fullscreen mode Exit fullscreen mode

The --dry-run flag shows the diff without writing. Always run this first.

Rule 1: TypedPropertyFromAssignsRector

The single biggest win on any pre-PHP-7.4 codebase. It looks at constructor assignments and adds property type declarations.

Before:

final class Order
{
    private $customerId;
    private $items;
    private $placedAt;

    public function __construct(
        int $customerId,
        array $items,
        \DateTimeImmutable $placedAt,
    ) {
        $this->customerId = $customerId;
        $this->items = $items;
        $this->placedAt = $placedAt;
    }
}
Enter fullscreen mode Exit fullscreen mode

After:

final class Order
{
    private int $customerId;
    private array $items;
    private \DateTimeImmutable $placedAt;

    public function __construct(
        int $customerId,
        array $items,
        \DateTimeImmutable $placedAt,
    ) {
        $this->customerId = $customerId;
        $this->items = $items;
        $this->placedAt = $placedAt;
    }
}
Enter fullscreen mode Exit fullscreen mode

This is the rule that gives you back hundreds of properties' worth of static analysis. PHPStan and Psalm can suddenly see the shape of your domain.

The gotcha: properties assigned conditionally get skipped (correctly, because the rule can't prove the type). Properties assigned from non-typed sources also get skipped. You'll get high-confidence changes only, which is the point.

Rule 2: ChangeArrayPushToArrayAssignRector

A small one but it adds up. array_push($items, $x) becomes $items[] = $x. The [] assignment is faster (no function call overhead) and reads better in most contexts.

Before:

public function addItem(OrderItem $item): void
{
    array_push($this->items, $item);
}
Enter fullscreen mode Exit fullscreen mode

After:

public function addItem(OrderItem $item): void
{
    $this->items[] = $item;
}
Enter fullscreen mode Exit fullscreen mode

On a hot path (a loop ingesting CSV rows, a queued job iterating a large dataset) the function-call elimination matters. On a one-off, it's a readability win.

The rule only fires on single-value pushes. array_push($items, $a, $b, $c) stays as-is because the [] form can't do multi-value in one statement.

Rule 3: ReturnTypeFromStrictTypedCallRector

Adds return types to methods that return the result of another typed call. The cleanest possible inference: if every return statement in the method returns from a method that already has a declared return type, the wrapping method gets that return type.

Before:

final class OrderRepository
{
    public function __construct(private \PDO $pdo) {}

    public function findById(int $id)
    {
        $stmt = $this->pdo->prepare('SELECT * FROM orders WHERE id = ?');
        $stmt->execute([$id]);

        return $this->hydrate($stmt->fetch(\PDO::FETCH_ASSOC));
    }

    private function hydrate(array $row): Order
    {
        return new Order($row['customer_id'], [], new \DateTimeImmutable($row['placed_at']));
    }
}
Enter fullscreen mode Exit fullscreen mode

After:

public function findById(int $id): Order
{
    $stmt = $this->pdo->prepare('SELECT * FROM orders WHERE id = ?');
    $stmt->execute([$id]);

    return $this->hydrate($stmt->fetch(\PDO::FETCH_ASSOC));
}
Enter fullscreen mode Exit fullscreen mode

hydrate() already declares : Order. The rule walks the return statements, sees they all funnel through hydrate(), and pulls the return type up.

This rule is conservative on purpose. It won't infer : Order|null from a single return null branch unless every branch is typed. That's the right call. False-confidence return types break consumers in subtle ways.

Rule 4: ReadOnlyPropertyRector

PHP 8.1 brought readonly properties. This rule finds properties that are only ever written in the constructor and marks them readonly. It's the closest thing PHP has to "make this value object actually immutable" with a single keystroke.

Before:

final class Money
{
    public function __construct(
        private int $amountInCents,
        private string $currency,
    ) {}

    public function amountInCents(): int
    {
        return $this->amountInCents;
    }

    public function currency(): string
    {
        return $this->currency;
    }
}
Enter fullscreen mode Exit fullscreen mode

After:

final class Money
{
    public function __construct(
        private readonly int $amountInCents,
        private readonly string $currency,
    ) {}

    public function amountInCents(): int
    {
        return $this->amountInCents;
    }

    public function currency(): string
    {
        return $this->currency;
    }
}
Enter fullscreen mode Exit fullscreen mode

The rule skips properties that get written outside the constructor. That includes setters, internal mutations, and clone-with-changes patterns. If your value object has a withCurrency() method that does $clone = clone $this; $clone->currency = $new;, the rule won't touch $currency.

You'll want to refactor those clone-mutations to use PHP 8.3's readonly with clone-with ($clone = clone $this; $clone->currency = $new; is illegal on readonly; you need (clone $this)->withCurrency($new) patterns). Worth doing separately.

Rule 5: RemoveExtraParametersRector

The dead-code rule with the highest signal-to-noise ratio. It finds method calls that pass more parameters than the method actually declares and removes the extras.

Before:

final class Logger
{
    public function info(string $message): void
    {
        error_log($message);
    }
}

// somewhere in legacy code:
$logger->info('order placed', ['order_id' => 42], 'high-priority');
Enter fullscreen mode Exit fullscreen mode

After:

$logger->info('order placed');
Enter fullscreen mode Exit fullscreen mode

Where does this come from? Refactors where someone narrowed a method's signature but didn't update every call site. PHP doesn't error on extra parameters by default. It silently drops them. Years of that accumulates.

The rule only fires when it can statically prove the called method's signature. Dynamic dispatch ($obj->$method(...)), __call, variadic methods: all skipped. Safe by construction.

One thing to watch: if you have a parameter that was being dropped but the caller still computed an expensive value for it, the value still gets computed. The rule removes the parameter, not the expression. You'll want a code review pass after.

Rule 6: ParamTypeFromStrictTypedPropertyRector

The mirror of rule 1. Where rule 1 adds property types from constructor parameters, this one adds parameter types to setters from property types.

Before:

final class User
{
    private string $email;

    public function setEmail($email): void
    {
        $this->email = $email;
    }
}
Enter fullscreen mode Exit fullscreen mode

After:

final class User
{
    private string $email;

    public function setEmail(string $email): void
    {
        $this->email = $email;
    }
}
Enter fullscreen mode Exit fullscreen mode

If the property is string and the setter assigns the parameter directly to that property, the parameter has to be string (or coercible, but you're not relying on coercion in 2026, right?). The rule adds the type.

Run rule 1 first. That populates the property types. Then this rule fills in the setter parameter types. Together they propagate type information from __construct outward through your whole class graph.

The dangerous rules, and why you skip them

Rector ships rules that change runtime behavior, not just types. These are the ones that bite.

RenameVariableToMatchNewTypeRector renames local variables when their inferred type changes. Sounds harmless. Isn't. If your team uses git blame to track history on a contentious file, this rule turns every line into "Rector changed this." Code review becomes impossible. Skip it.

AddArrowFunctionReturnTypeRector adds return types to arrow functions. The issue is that arrow functions in PHP often pipe through array_map/array_filter chains where the inferred type is too narrow. You'll get : int on a function that the chain expects to also return null. Runtime error six months later when the data has an edge case.

LevelSetList::UP_TO_PHP_82 and friends. These are bundles. They run dozens of rules at once with no isolation. The first time anything in the bundle has a subtle bug on your codebase, you have no idea which rule did it. Always run rules individually until you trust them.

RemoveUselessParamTagRector strips @param tags when the parameter has a native type declaration. Looks tidy. But many codebases use @param tags for richer types than PHP supports: @param array<string, Order> $ordersByUuid. The rule reads the native type (array) and assumes the doc tag is redundant. It isn't. You lose the type information static analyzers depend on.

The pattern: any rule that removes code, renames things, or runs as part of a bundle deserves a long second look. The six in this post all add. They add types, they add modifiers, they cut dead parameters that were already being silently dropped. Additive changes are reversible. Renames and removals aren't.

Setting up Rector in CI without breaking the team

The mistake: a single PR titled "Run Rector on entire codebase". 14,000 lines. Nobody reviews it properly. It merges. The next week, three regressions land that nobody can trace because the diff is too large to bisect.

The pattern that works:

Step 1. Add Rector to composer.json as a dev dependency. Commit the rector.php config (the six rules above). No code changes yet.

Step 2. Add a CI job that runs Rector in --dry-run mode and fails if there are changes:

# .github/workflows/rector.yml
name: Rector

on:
  pull_request:
    paths:
      - '**.php'
      - 'rector.php'
      - 'composer.lock'

jobs:
  rector:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4

      - uses: shivammathur/setup-php@v2
        with:
          php-version: '8.2'
          coverage: none

      - name: Cache Composer
        uses: actions/cache@v4
        with:
          path: ~/.composer/cache
          key: composer-${{ hashFiles('composer.lock') }}

      - name: Install
        run: composer install --no-progress --prefer-dist

      - name: Cache Rector
        uses: actions/cache@v4
        with:
          path: var/cache/rector
          key: rector-${{ hashFiles('rector.php', 'composer.lock') }}

      - name: Run Rector (dry-run)
        run: vendor/bin/rector process --dry-run --no-progress-bar
Enter fullscreen mode Exit fullscreen mode

That cache step matters. Rector's first run on a large codebase can take 4-5 minutes. With the cache hit, you're at 30 seconds.

Step 3. Apply Rector module-by-module, not all at once. Pick one bounded context (app/Orders, src/Payments, whatever) and run:

vendor/bin/rector process app/Orders
Enter fullscreen mode Exit fullscreen mode

Review the diff. Run the tests for that module. Open one PR per module. Each PR is small enough to review properly.

Step 4. Once every module's been through Rector once, the dry-run check in CI catches new violations the moment someone writes code that doesn't follow the rules. No more drift.

Step 5. When you're confident, add one more rule to rector.php. Run the cycle again. Each addition is a deliberate decision, not a 600-rule blast radius.

What you get when this lands

PHPStan goes from level 5 to level 7 with no new code. Properties have types. Returns have types. Parameters have types. Static analysis stops being aspirational and starts being useful.

Code review velocity goes up too. You're not arguing about whether to add a type. Rector adds it, the PR diff is mechanical, the human review focuses on logic.

And the legacy module that nobody wanted to touch becomes touchable. Once the types are in, the IDE actually helps. Refactoring stops being archaeology.

The six rules above are the floor. Once you trust them, the next batch (AddVoidReturnTypeWhereNoReturnRector, BoolReturnTypeFromBooleanStrictReturnsRector, the explicit null rules) extends the same idea. Always one rule at a time. Always dry-run first. Always module-by-module.

The 14,000-line PR was the wrong unit. Six rules, one module, one PR. That's the unit.

Which Rector rules have you shipped without regret, and which one bit you? Drop them in the comments.


If this was useful

Rector is great at the mechanical layer: types, modifiers, dead parameters. The harder problem it doesn't solve is the architectural one. The controller that knows about the database, the service that calls 14 other services, the domain logic scattered across three layers. That's the work Decoupled PHP is about: the shape your codebase wants to be in once you've stopped fighting your own structure.

Decoupled PHP — Clean and Hexagonal Architecture for Applications That Outlive the Framework

Available on Kindle, Paperback, and Hardcover. English, German, and Japanese editions out now — Portuguese and Spanish coming soon.

Top comments (0)