Rector: Automated Refactoring for PHP Version Upgrades
AI generated
<?php
8.4
PHP · Rector · Refactoring · PHP 8.4
Rector: Automated Refactoring for PHP Version Upgrades
from PHP 7.4 to PHP 8.4 without manual search and replace

Rector automated refactoring replaces manual search and replace during PHP version upgrades with AST-based code rewriting: constructor property promotion, readonly properties, match expressions and the nullsafe operator are applied mechanically and repeatably across an entire codebase, including dry-run preview, custom rules and CI integration against regressions.

18 min read rector.php · LevelSetList · PHP 8.4 set · custom rules PHP 7.4 to 8.4 · CI/CD

1. What Rector is and how it differs from PHPStan and PHP-CS-Fixer

Rector automated refactoring means a tool understands PHP code not as text but as an abstract syntax tree (AST) and rewrites it deliberately. Rector parses every file with nikic/php-parser into an AST, applies one or more rules to individual nodes, and prints the modified tree back out as readable PHP code. The crucial difference from a search-and-replace script: Rector knows the types, scope and context of a node before it changes it, and can therefore make decisions that a plain text pattern cannot make.

Rector is often confused with PHPStan and PHP-CS-Fixer, but it solves a different problem. PHPStan analyzes code statically and reports problems, but it never changes a single line, the fix stays manual work. PHP-CS-Fixer normalizes formatting, indentation and brace placement, but it does not touch the logic or structure of the code. Rector automated refactoring goes one step further: it rewrites the actual structure, for example when a switch statement is replaced by a match expression, or a classic constructor is converted into constructor property promotion.

For teams who need to move a codebase from PHP 7.4 to PHP 8.4, that is exactly the decisive advantage. Instead of manually searching hundreds of files and replacing patterns one by one, Rector applies the matching rules mechanically and repeatably across the entire codebase. The result is deterministic: the same input always produces the same output, something that is hard to guarantee when several developers make manual changes.

2. Configuring rector.php: rule sets, LevelSetList and the PHP 8.4 set

The starting point for Rector automated refactoring is the rector.php file in the project root. It returns a RectorConfig object configured through a fluent interface API: withPaths() defines which directories get processed, withSkip() excludes individual files or rules, and withPhpSets() activates the rule set for a given target PHP version. In older Rector versions, the LevelSetList class handled this via withSets([LevelSetList::UP_TO_PHP_84]), the current API bundles the same idea more compactly in withPhpSets(php84: true).

Alongside the PHP 8.4 set there are prepared sets for dead code removal (deadCode), type declaration completion (typeDeclarations) and visibility tightening (privatization), all activated together through withPreparedSets(). Anyone who also needs a project-specific rule attaches it through withRules(), without touching the rest of the set. This composability is one reason why Rector automated refactoring can be introduced into existing projects without having to adopt an entire rule set at once.


<?php

declare(strict_types=1);

use Rector\Config\RectorConfig;

// rector.php - main configuration entry point
return RectorConfig::configure()
    ->withPaths([
        __DIR__ . '/src',
        __DIR__ . '/tests',
    ])
    ->withSkip([
        __DIR__ . '/src/Legacy/OldBootstrap.php',
        // Skip a single rule for a specific path only
        \Rector\CodeQuality\Rector\If_\ExplicitBoolCompareRector::class => [
            __DIR__ . '/src/Legacy',
        ],
    ])
    // Target rule set: rewrite constructs up to PHP 8.4
    ->withPhpSets(php84: true)
    // Additional prepared sets: dead code, type declarations, visibility
    ->withPreparedSets(
        deadCode: true,
        typeDeclarations: true,
        privatization: true,
    )
    ->withRules([
        \Rector\CodingStyle\Rector\Class_\AddArrayDefaultToArrayPropertyRector::class,
    ])
    ->withCache(cacheDirectory: __DIR__ . '/var/rector-cache');

3. Dry-run vs. apply mode: running Rector safely

Rector automated refactoring knows two fundamental execution modes: dry-run mode and apply mode. In dry-run mode (--dry-run), Rector analyzes every file, mentally applies the configured rules and prints the resulting diff to the terminal without touching a single file. This is the safe default path to see, before a single line is written, how many files would be affected and what the concrete changes look like ahead of any larger change.

Only once the dry-run diff has been reviewed and judged reasonable does apply mode follow, without the flag, which actually writes the changes to the files. Rector caches the parse state of every file based on a hash value to speed up repeated runs. After a change to the rector.php configuration itself, this cache can return stale results, which is why --clear-cache is mandatory in such cases, to explicitly invalidate the cache and force a clean restart.


# Preview changes without touching any file
vendor/bin/rector process --dry-run

# After a config change: invalidate the file-hash cache first
vendor/bin/rector process --dry-run --clear-cache

# Review the proposed diff manually
git diff --stat

# Apply the reviewed changes for real
vendor/bin/rector process

# Run the test suite immediately to confirm behavior is unchanged
vendor/bin/phpunit

# Stage and commit in reviewable chunks
git add -p
git commit -m "Rector: apply PHP 8.4 rule set to src/Domain"

4. A concrete transformation: from PHP 7.4 to PHP 8.4

A concrete example shows how deeply Rector automated refactoring can change a class body. A typical PHP 7.4 class declares private properties, assigns them line by line in the constructor, uses a switch statement for status evaluation, and checks nested for null before accessing a nested object. Each of these three spots has a shorter, safer equivalent in PHP 8.0 through 8.4, which Rector recognizes and applies automatically.

The corresponding PHP 8.4 rule replaces the manual assignment in the constructor with constructor property promotion using the readonly modifier, turns the switch statement into a match expression with enforced exhaustiveness, and replaces the nested null check with the nullsafe operator ?->. Callers of the class can additionally benefit from named arguments, for example new OrderProcessor(logger: $logger, currency: 'EUR'), which Rector does not enforce but which becomes practical only once the parameters are promoted and clearly named.


// BEFORE: PHP 7.4 style
class OrderProcessor
{
    private LoggerInterface $logger;
    private PaymentGatewayInterface $gateway;
    private string $currency;

    public function __construct(
        LoggerInterface $logger,
        PaymentGatewayInterface $gateway,
        string $currency
    ) {
        $this->logger = $logger;
        $this->gateway = $gateway;
        $this->currency = $currency;
    }

    public function statusLabel(int $status): string
    {
        switch ($status) {
            case self::STATUS_NEW:
                $label = 'new';
                break;
            case self::STATUS_PAID:
                $label = 'paid';
                break;
            case self::STATUS_SHIPPED:
                $label = 'shipped';
                break;
            default:
                $label = 'unknown';
        }
        return $label;
    }

    public function customerCity(?Customer $customer): ?string
    {
        if ($customer === null) {
            return null;
        }
        $address = $customer->getAddress();
        if ($address === null) {
            return null;
        }
        return $address->getCity();
    }
}

// AFTER: PHP 8.4 style, generated by Rector
final class OrderProcessor
{
    public function __construct(
        private readonly LoggerInterface $logger,
        private readonly PaymentGatewayInterface $gateway,
        private readonly string $currency,
    ) {
    }

    public function statusLabel(int $status): string
    {
        return match ($status) {
            self::STATUS_NEW => 'new',
            self::STATUS_PAID => 'paid',
            self::STATUS_SHIPPED => 'shipped',
            default => 'unknown',
        };
    }

    public function customerCity(?Customer $customer): ?string
    {
        return $customer?->getAddress()?->getCity();
    }
}

5. Dead code removal and type declaration sets

Beyond pure version upgrades, Rector automated refactoring also covers cleanup work that accumulates in grown codebases. The deadCode set detects, for example, private methods that are never called, properties that are never read, and conditions that can never be true given the known types. Such dead paths are not merely reported like they would be by PHPStan, they are removed directly, as long as removal is provably safe.

The typeDeclarations set adds missing parameter, return and property types based on docblocks, default values and call context. A method whose return type previously lived only in an @return comment gets the native type declaration in the method signature. The privatization set tightens visibility where a property or method is only ever used inside its own class, turning it from public or protected into private. Together these sets noticeably reduce the attack surface and the cognitive load of reading a class.

6. Writing a custom Rector rule

The prepared sets cover most standard cases, but project-specific patterns require a custom rule. Rector automated refactoring allows exactly that through the AbstractRector class: a custom rule implements getNodeTypes() to declare which AST node types it wants to visit, and refactor() to perform the actual rewrite. If refactor() returns null, the node stays unchanged, if it returns the modified node, Rector replaces it in the tree.

The getRuleDefinition() method provides a machine-readable description with a before/after code sample, which Rector uses both for auto-generated documentation and for tests of the custom rule itself. A typical use case: an internal logger interface renames a deprecated warn() method to warning(), and instead of hunting that call across a hundred files by hand, a small, tested Rector rule takes over the renaming project-wide.


<?php

declare(strict_types=1);

namespace App\Rector;

use PhpParser\Node;
use PhpParser\Node\Expr\MethodCall;
use PhpParser\Node\Identifier;
use Rector\Rector\AbstractRector;
use Symplify\RuleDocGenerator\ValueObject\CodeSample;
use Symplify\RuleDocGenerator\ValueObject\RuleDefinition;

/**
 * Custom rule: renames deprecated Logger::warn() calls to Logger::warning()
 */
final class RenameLegacyLoggerMethodRector extends AbstractRector
{
    public function getRuleDefinition(): RuleDefinition
    {
        return new RuleDefinition(
            'Renames deprecated Logger::warn() calls to Logger::warning()',
            [
                new CodeSample(
                    '$logger->warn("message");',
                    '$logger->warning("message");'
                ),
            ]
        );
    }

    public function getNodeTypes(): array
    {
        return [MethodCall::class];
    }

    public function refactor(Node $node): ?Node
    {
        if (! $node instanceof MethodCall) {
            return null;
        }

        if (! $this->isName($node->name, 'warn')) {
            return null;
        }

        $node->name = new Identifier('warning');

        return $node;
    }
}

7. Rolling out large automated diffs safely

A single Rector run across a large legacy codebase can change thousands of lines in a single diff, which makes code review practically impossible. The safe approach is to run Rector automated refactoring in small, traceable steps: limit withPaths() to a single module or directory, review the dry-run diff for exactly that slice, apply it, run the tests, commit, and only then move on to the next directory.

After every chunk, the full test suite should run, not just a partial subset, because Rector rules occasionally touch edge cases that only show up in independent tests. When the rector.php configuration itself changes, --clear-cache is mandatory, since the file-hash cache would otherwise return stale analysis results and hide changes. A feature branch per chunk with its own pull request keeps the diffs manageable and lets reviewers focus on semantic rather than mechanical changes.

8. CI integration: preventing regressions to old patterns

Without a safety net in the CI pipeline, old patterns creep back in quickly after a one-off Rector run: a new pull request adds a classic switch statement instead of a match expression, or a developer writes a constructor without property promotion. Rector automated refactoring can be integrated into the CI pipeline for exactly this reason, by running the same command that was used locally for the migration as a check step with --dry-run.

If Rector reports a pending diff in dry-run mode, that means at least one file has drifted from the configured rule set, and the pipeline step should fail with a non-zero exit code. This way, Rector automated refactoring prevents not only the one-time migration but also the gradual relapse into old patterns, without a reviewer ever having to hunt down every switch statement by hand.


name: rector-check
on: [pull_request]

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

      - uses: shivammathur/setup-php@v2
        with:
          php-version: '8.4'

      - run: composer install --no-interaction --prefer-dist

      # Fail the build if any file still needs a Rector rewrite
      - run: vendor/bin/rector process --dry-run --clear-cache

      - run: vendor/bin/phpunit

9. Rector compared to manual refactoring

Rector, PHPStan and PHP-CS-Fixer are often used together in projects because they cover different layers of the same quality problem. PHP-CS-Fixer normalizes only formatting, PHPStan finds type and logic errors without fixing them, and Rector is the only one of the three tools that actually rewrites the structure of the code. The table below sets Rector automated refactoring directly against classic manual refactoring.

Criterion Manual refactoring Rector automated refactoring Effect
Speed Days to weeks across thousands of files Minutes for the entire codebase Migration becomes plannable instead of effort-dependent
Consistency Depends on the person and the day Deterministic, same rule everywhere No style drift between modules
Risk on large diffs High, hard to review Reducible through chunking and tests Reviewable, traceable steps
Reversibility No consistent rollback pattern One git commit per chunk, cleanly revertible Individual rules can be reverted in isolation
Scaling to legacy code Drops off rapidly with project size Stays constant as the codebase grows Even multi-million-line codebases stay migratable

The speed difference becomes especially visible on version upgrades: where a team budgets weeks for a manual migration from PHP 7.4 to PHP 8.4, Rector processes the same codebase in minutes and produces a reproducible, reviewable diff instead of a series of independent, potentially inconsistent manual changes.

10. Summary

Rector automated refactoring solves a problem that plain search and replace and pure analysis tools like PHPStan cannot solve: the mechanical, AST-based rewriting of code across an entire codebase. The rector.php configuration with withPhpSets() and withPreparedSets() bundles rules for PHP version upgrades, dead code removal and type declarations into a single, versionable file. Dry-run mode makes every change visible before it is applied, while --clear-cache prevents a stale cache from returning wrong results.

Custom Rector rules through AbstractRector extend the tool with project-specific refactorings that no prepared set covers. Large migrations run safely in small, tested chunks instead of one giant diff, and integrating Rector automated refactoring into the CI pipeline with --dry-run prevents old patterns from quietly returning after the migration. Together this replaces a discipline that used to rely purely on manual diligence with a repeatable, automated process.

Rector automated refactoring, the key takeaways at a glance

Configuration

rector.php with withPhpSets() and withPreparedSets() bundles PHP 8.4 rules, dead code removal and type declarations into one file.

Safe execution

Dry-run before every apply, --clear-cache after config changes, a full test run after every chunk.

Custom rules

AbstractRector with getNodeTypes() and refactor() for project-specific refactorings that no set covers.

CI integration

rector --dry-run as a pipeline step prevents the relapse into old patterns after the migration.

11. FAQ: Rector Automated Refactoring

1What exactly is Rector automated refactoring?
Rector reads PHP code as an abstract syntax tree and rewrites it according to configurable rules instead of just searching text. It automates version upgrades, dead code removal and type declarations reproducibly across the whole codebase.
2How does Rector differ from PHPStan?
PHPStan reports problems statically but never changes a line of code. Rector rewrites the affected constructs directly, for example missing type declarations that PHPStan would only flag.
3How does Rector differ from PHP-CS-Fixer?
PHP-CS-Fixer normalizes only formatting. Rector changes the actual structure, such as switch to match, which goes beyond pure style corrections.
4Dry-run vs. apply mode?
--dry-run shows the diff in the terminal without changing files. Without that flag Rector actually writes the changes. Review dry-run before every larger application.
5What does --clear-cache do?
Rector caches each file's parse state by hash. After a config change the cache would otherwise return stale results, --clear-cache forces a clean restart.
6How do I write a custom Rector rule?
Extend AbstractRector, implement getNodeTypes() for the target nodes and refactor() for the rewrite. getRuleDefinition() provides the before/after sample for docs and tests.
7Rolling out large diffs safely?
Limit scope to individual modules, review the dry-run diff, apply, run the full test suite, then commit. Small, tested chunks instead of one giant diff.
8Can Rector introduce bugs?
Yes, with complex constructs. That is why the full test suite should run after every chunk and the dry-run diff reviewed before every application.
9How do I integrate Rector into CI?
Run the same migration command as a pipeline step with --dry-run. A pending diff fails the build with a non-zero exit code.
10Does Rector replace code reviews?
No. Rector automates the mechanical application, but the resulting diff should still be reviewed by a human, especially for custom rules.

Mironsoft

PHP version upgrades, legacy modernization and CI hardening

Move a codebase to PHP 8.4 without weeks of manual work?

We configure rector.php for your project, write project-specific custom rules, and roll out large automated diffs safely in small, tested steps, including CI hardening against the relapse into old patterns.

Configuration

Set up rector.php with the right PHP 8.4 rule sets and project-specific skip rules

Custom rules

Develop and test custom Rector rules for project-specific refactorings

CI integration

Integrate rector --dry-run into the pipeline and prevent regressions for good