DEV Community

Cover image for Writing a Custom Rector Rule: Automating a Refactor Across 500 Files
Gabriel Anhaia
Gabriel Anhaia

Posted on

Writing a Custom Rector Rule: Automating a Refactor Across 500 Files


You have 500 files that call Carbon::now(). Your team decided a year ago that mutable date objects were a bug factory. Carbon gives you CarbonImmutable as the fix. But 500 call sites is too many for find-and-replace, because a blind swap also catches new Carbon($raw) and typed parameters and the one service that genuinely needs the mutable variant.

You reach for Rector. Then you check the config rules and find that RenameStaticMethodRector renames a static method by name, and RenameClassRector renames a class everywhere. Neither of them says "swap the class only on these five factory methods, add the import, and leave everything else alone."

That's the gap a custom rule fills. Rector's built-in rules cover the common shapes. When your refactor has a condition the config rules can't express, you write your own node visitor. It's less work than it looks.

What a Rector rule actually is

Rector parses PHP into an AST with nikic/php-parser, then walks the tree. A rule is a class that declares which node types it cares about and returns a rewritten node (or null to skip). That's the whole contract:

public function getNodeTypes(): array;
public function refactor(Node $node): ?Node;
Enter fullscreen mode Exit fullscreen mode

getNodeTypes() tells Rector "call me on every StaticCall node." refactor() gets each matching node, inspects it, and either mutates and returns it or returns null to leave it untouched. Rector handles the traversal, the file writing, the diff.

Install it as a dev dependency:

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

Reading the node you get

For Carbon::now(), the parser produces a StaticCall node with three parts:

  • $node->class — a Name node holding Carbon\Carbon
  • $node->name — an Identifier node holding now
  • $node->args — the argument list (empty here)

Rector runs a name resolver before your rule sees the tree, so $node->class carries the fully-qualified name even when the file imported Carbon with a use statement. That means you compare against Carbon\Carbon, not whatever short alias the file happened to use.

The AbstractRector base class gives you helpers for those comparisons: isName() for a single name, isNames() for a set.

The rule

Here's the full rule. It swaps the class on a fixed set of factory methods and skips everything else:

<?php

declare(strict_types=1);

namespace App\Rector;

use PhpParser\Node;
use PhpParser\Node\Expr\StaticCall;
use PhpParser\Node\Name\FullyQualified;
use Rector\Rector\AbstractRector;
use Symplify\RuleDocGenerator\ValueObject\CodeSample\CodeSample;
use Symplify\RuleDocGenerator\ValueObject\RuleDefinition;

final class CarbonToImmutableRector extends AbstractRector
{
    private const FACTORIES = [
        'now', 'today', 'tomorrow',
        'yesterday', 'parse',
    ];

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

    public function refactor(Node $node): ?Node
    {
        if (! $node->class instanceof Node\Name) {
            return null;
        }

        if (! $this->isName($node->class, 'Carbon\Carbon')) {
            return null;
        }

        if (! $this->isNames($node->name, self::FACTORIES)) {
            return null;
        }

        $node->class = new FullyQualified(
            'Carbon\CarbonImmutable'
        );

        return $node;
    }

    public function getRuleDefinition(): RuleDefinition
    {
        return new RuleDefinition(
            'Swap Carbon factory calls for CarbonImmutable',
            [new CodeSample(
                'Carbon::now()->addDays(3)',
                'CarbonImmutable::now()->addDays(3)'
            )]
        );
    }
}
Enter fullscreen mode Exit fullscreen mode

Three guards, then one mutation. The first guard skips dynamic class expressions ($class::now()), the second skips any static call that isn't on Carbon, the third skips Carbon methods that aren't factories. Only what survives all three gets rewritten.

getRuleDefinition() isn't decoration. Rector's docs generator reads it, and the CodeSample doubles as living documentation of the before/after. Write it honestly and future-you knows what the rule does without reading refactor().

Registering and autoloading it

Add the rule to your rector.php:

<?php

declare(strict_types=1);

use App\Rector\CarbonToImmutableRector;
use Rector\Config\RectorConfig;

return RectorConfig::configure()
    ->withPaths([__DIR__ . '/src'])
    ->withRules([CarbonToImmutableRector::class])
    ->withImportNames();
Enter fullscreen mode Exit fullscreen mode

The rule emits a FullyQualified name, which renders as \Carbon\CarbonImmutable. withImportNames() is what turns that back into a short CarbonImmutable with a use statement added at the top of the file. Without it, your diff is full of leading-backslash names.

Rector needs to autoload the rule class. Point your composer.json dev autoload at it:

{
    "autoload-dev": {
        "psr-4": {
            "App\\Rector\\": "utils/rector/src"
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

Then composer dump-autoload. The rule lives in utils/rector/src, out of your production autoload path.

Dry-run before you trust it

Never let a fresh rule write to disk on the first run. Look at the diff first:

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

You'll get a unified diff per file and nothing written. Read a handful of the changes by hand. Check the edge you were worried about: search the dry-run output for new Carbon( and confirm none of those turned into new CarbonImmutable(. If a case slips through, add a guard and run again. The dry-run loop is where you build confidence.

Testing the rule with fixtures

A custom rule earns a test, because you'll extend it later and want a red bar when a guard breaks. Rector ships a test harness built on before/after fixture files.

The test case:

<?php

declare(strict_types=1);

namespace App\Rector\Tests;

use Rector\Testing\PHPUnit\AbstractRectorTestCase;
use PHPUnit\Framework\Attributes\DataProvider;

final class CarbonToImmutableRectorTest
    extends AbstractRectorTestCase
{
    #[DataProvider('provideData')]
    public function test(string $filePath): void
    {
        $this->doTestFile($filePath);
    }

    public static function provideData(): \Iterator
    {
        return self::yieldFilesFromDirectory(
            __DIR__ . '/Fixture'
        );
    }

    public function provideConfigFilePath(): string
    {
        return __DIR__ . '/config/rule.php';
    }
}
Enter fullscreen mode Exit fullscreen mode

Fixture files must be named *.php.inc and live under /Fixture (e.g. carbon_factory.php.inc) — that's the pattern yieldFilesFromDirectory() discovers. Save one as SomeCase.php and the suite collects nothing and runs green on zero tests. Each fixture holds the input and expected output in one file, split by -----:

<?php

namespace App\Rector\Tests\Fixture;

use Carbon\Carbon;

$due = Carbon::now()->addDays(3);

?>
-----
<?php

namespace App\Rector\Tests\Fixture;

use Carbon\CarbonImmutable;

$due = CarbonImmutable::now()->addDays(3);

?>
Enter fullscreen mode Exit fullscreen mode

Write a second fixture with no ----- separator holding new Carbon($raw). A file without the separator asserts "Rector changes nothing here." That's your guard against the false positive you were worried about, pinned in CI.

The config at __DIR__ . '/config/rule.php' registers only the one rule under test, so the fixtures exercise it in isolation.

The gotcha that makes this a custom rule

The reason this isn't a mechanical rename: CarbonImmutable changes runtime behavior. Mutating methods on Carbon alter the object in place. On CarbonImmutable, the same methods return a new instance and leave the original alone.

$start = Carbon::now();
$start->addDays(3);        // $start moved forward
echo $start->diffInDays(); // 3

$start = CarbonImmutable::now();
$start->addDays(3);        // return value discarded
echo $start->diffInDays(); // 0
Enter fullscreen mode Exit fullscreen mode

Any code that relied on the in-place mutation breaks silently after the swap. The rule can't detect that from the AST, and it shouldn't try. What it can do is make the change mechanical and reviewable so a human catches the two or three call sites that discard the return value. That's why the rollout matters as much as the rule.

Rolling it out across 500 files

The wrong move is one PR titled "migrate all Carbon usage." 500 files, nobody reviews it, the two behavioral breaks hide in the noise.

The pattern that works:

  1. Scope the config to one directory. Point withPaths() at src/Billing, run --dry-run, read every change.
  2. Run for real, then the tests. vendor/bin/rector process src/Billing, then the module's test suite. Green means the mutation-discard cases were already safe there.
  3. One PR per module. Small enough that a reviewer actually reads the diff and questions the suspicious mutations.
  4. Add a CI dry-run gate. Once a module is migrated, rector process --dry-run failing on new violations keeps Carbon::now() from creeping back in.

The rule is the easy part. The discipline of moving one bounded context at a time is what keeps a 500-file refactor from becoming a 500-file incident.

Custom Rector rules are worth writing the moment your migration has a condition the config rules can't express. The node visitor is a small class. The test harness is one fixture directory. What you get back is a refactor you can apply, review, and reverse one module at a time, instead of a search-and-replace you cross your fingers over.


If this was useful

A rule like this lives in utils/, at the edge of the codebase, precisely because it's a build-time concern and not domain logic. Keeping the mechanical tooling out of your src/ tree is the same instinct that keeps the framework out of your domain: the code that outlives a refactor is the code that never depended on the thing you refactored. That boundary discipline is what Decoupled PHP is about.

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)