Custom Rule

First, make sure it's not covered by any existing Rectors. Let's say we want to change method calls from set* to change*.

<?php

$user = new User();
-$user->setPassword('123456');
+$user->changePassword('123456');

1. Create a New Rector and Implement Methods

Create a class that extends Rector\Rector\AbstractRector. It will inherit useful methods e.g. to check node type and name. See the source (or type $this-> in an IDE) for a list of available methods.

<?php

namespace Utils\Rector\Rector;

use PhpParser\Node;
use PhpParser\Node\Identifier;
use PhpParser\Node\Expr\MethodCall;
use Rector\Rector\AbstractRector;

final class MyFirstRector extends AbstractRector
{
    /**
     * @return array<class-string<Node>>
     */
    public function getNodeTypes(): array
    {
        // what node types are we looking for?
        // pick from
        // https://github.com/rectorphp/php-parser-nodes-docs/
        return [MethodCall::class];
    }

    /**
     * @param MethodCall $node
     */
    public function refactor(Node $node): ?Node
    {
        $methodCallName = $this->getName($node->name);
        if ($methodCallName === null) {
            return null;
        }

        // we only care about "set*" method names
        if (! str_starts_with($methodCallName, 'set')) {
            // return null to skip it
            return null;
        }

        $newMethodCallName = preg_replace(
            '#^set#', 'change', $methodCallName
        );

        $node->name = new Identifier($newMethodCallName);

        // return $node if you modified it
        return $node;
    }
}

File Structure

This is how the file structure for custom rule in your own project will look like:

/src
    SomeCode.php

/utils
    /rector
        /src
            /Rector
                MyFirstRector.php

        /tests
            /Rector
                /MyFirstRector
                    /Fixture
                        test_fixture.php.inc
                    /config
                        config.php
                    MyFirstRectorTest.php

rector.php
composer.json

Update composer.json

We also need to load Rector rules in composer.json:

{
    "autoload": {
        "psr-4": {
            "App\\": "src"
        }
    },
    "autoload-dev": {
        "psr-4": {
            "Utils\\Rector\\": "utils/rector/src",
            "Utils\\Rector\\Tests\\": "utils/rector/tests"
        }
    }
}

After adding this to composer.json, be sure to reload Composer's class map:

composer dump-autoload

2. Register It

<?php

use Utils\Rector\Rector\MyFirstRector;
use Rector\Config\RectorConfig;

return RectorConfig::configure()
    ->withRules([
        MyFirstRector::class
    ]);

3. Let Rector Refactor Your Code

The rector.php configuration is loaded by default, so we can skip it.

# see the diff first
vendor/bin/rector process src --dry-run

# if it's ok, apply
vendor/bin/rector process src

That's it!

4. Write a Test

Writing a test for your custom rule will save you a lot of time in future debugging. Rector provides a structured way of running your rule on different snippets of code, so you can validate it works as expected in a variety of cases.

There are 2 composer packages needed to run tests for your custom rule:

  • phpunit/phpunit: the testing framework
  • rector/rector: this contains the AbstractRectorTestCase class to simplify test configuration

MyFirstRectorTest.php

This class handles the heavy lifting of preparing Rector & running it against your test cases. The usual structure of the test class is as follows:

<?php

declare(strict_types=1);

namespace Utils\Rector\Tests\Rector\MyFirstRector;

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

final class MyFirstRectorTest 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/config.php';
    }
}

There are 3 methods in this test class:

  • public function test(string $filePath): void:
    • This method helps PHPUnit detect the test
    • For $filePath, we use a PHPUnit DataProvider
    • This triggers a run for every test file in your Fixture directory
  • public static function provideData(): Iterator:
    • Using self::yieldFilesFromDirectory it iterates over all test cases you provided
    • By default this only picks up files ending on .php.inc
  • public function provideConfigFilePath(): string:
    • Returns a rector.php-styled file configuring the minimal set of rules needed to run the tests (including MyFirstRector)

config/config.php

This is a rector.php-styled file. If your rule is not configurable, it will look like this:

use Rector\Config\RectorConfig;
use Utils\Rector\Rector\MyFirstRector;

return RectorConfig::configure()
    ->withRules([
        MyFirstRector::class,
    ]);

This essentially reflects how you would use your rule in real life.

Fixture/*.php.inc

These are the snippets of code on which Rector runs your custom rule. To prevent automated tools from picking up those snippets, add an extra suffix .inc (so example.php becomes example.php.inc).

There are two options for every test file: either the snippet should be changed by your rule, or it should stay the same.

Fixture/test_fixture.php.inc

Assuming your rule changes $user->setPassword('123456') to $user->changePassword('123456'), this is an example snippet:

<?php

namespace Utils\Rector\Tests\Rector\MyFirstRector\Fixture;

class SomeClass
{
    public function handlePasswordChange(User $user, string $password)
    {
        $user->setPassword($password);
    }
}

?>
-----
<?php

namespace Utils\Rector\Tests\Rector\MyFirstRector\Fixture;

class SomeClass
{
    public function handlePasswordChange(User $user, string $password)
    {
        $user->changePassword($password);
    }
}

?>

This file contains a "before" and "after" situation, separated by exactly 5 dashes: -----. The AbstractRectorTestCase detects the -----, runs the rules configured in config/config.php on the snippet before the ----- and asserts that the changed file exactly matches the snippet after the -----.

Fixture/skip_rule_test_fixture.php.inc

There are cases where you want to check that your rule is not applied. The file structure is very similar to Fixture/test_fixture.php.inc with 1 exception: it only contains a "before" situation.

It is not necessary to prefix the fixture with skip, but doing so makes it easy to see that no changes are expected.

<?php

namespace Utils\Rector\Tests\Rector\MyFirstRector\Fixture;

class SomeClass
{
    public function handleLogin(User $user, string $password)
    {
        return $user->isCorrectPassword($password);
    }
}

?>

There is no -----, so AbstractRectorTestCase runs the rules on the snippet and asserts that no changes are applied.

Running Your Tests

vendor/bin/phpunit tests