Skip to content

4. Writing your own rule

github-actions[bot] edited this page Jun 8, 2026 · 2 revisions

4. Writing your own rule

A rule is a small class under src/Rules/ that extends Rector\Rector\AbstractRector and answers three questions: which node types do I care about, how do I transform a matching node, and what's my documentation example.

4.1 Anatomy of a rule

<?php

declare(strict_types=1);

namespace Xoops\Rector\Rules;

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

final class MyExampleRector extends AbstractRector
{
    /** Which AST node classes Rector should hand to refactor(). Narrow = faster. */
    public function getNodeTypes(): array
    {
        return [FuncCall::class];
    }

    /** Return a new/mutated node to replace $node, or null to leave it unchanged. */
    public function refactor(Node $node): ?Node
    {
        if (!$node instanceof FuncCall || !$node->name instanceof Name) {
            return null;
        }
        if ($node->name->toLowerString() !== 'old_function') {
            return null;
        }
        $node->name = new Name('new_function');
        return $node;
    }

    public function getRuleDefinition(): RuleDefinition
    {
        return new RuleDefinition(
            'Renames old_function() to new_function().',
            [new CodeSample('old_function($x);', 'new_function($x);')]
        );
    }
}

4.2 The rules that matter

  1. Match precisely, bail early. Re-check the instance and every shape assumption at the top of refactor() and return null the moment something doesn't fit. A loose rule is how you corrupt code.
  2. Idempotency / fix-point. Rector runs every rule repeatedly until nothing changes. Your output must not re-match your own rule, or you get an infinite loop. (E.g. after renaming oldnew, the node is now new, which your check skips.)
  3. ?Node return. Return the changed node (mutated or freshly built) to replace it, or null to skip. To remove a statement, return \PhpParser\NodeVisitor::REMOVE_NODE (widen the return type to int|null — see RemoveFuncCallRector).
  4. Find the node classes you need by running Rector with --debug, or read php-parser's PhpParser\Node\* (Expr, Stmt, Scalar). Common ones: FuncCall, MethodCall, StaticCall, New_, Assign, ArrayDimFetch, Variable, Name, Identifier, Scalar\String_, Scalar\Int_, Expr\ConstFetch.
  5. Case-insensitivity. Function/method names are case-insensitive in PHP. Compare with ->toLowerString() (on Name) or strtolower(...).

4.3 Configurable rules

To accept configuration from the set file, implement ConfigurableRectorInterface and a configure() method (see RemoveFuncCallRector, RenameMethodCallByNameRector, RenameMethodWithAddedFirstArgRector, ServerSuperglobalToXmfRequestRector):

use Rector\Contract\Rector\ConfigurableRectorInterface;

final class RenameMethodCallByNameRector extends AbstractRector implements ConfigurableRectorInterface
{
    /** @var array<string,string> */
    private array $map = [];

    /** @param mixed $configuration */
    public function configure($configuration): void
    {
        $this->map = is_array($configuration) ? $configuration : [];
    }
    // … refactor() reads $this->map …
}

Configured in the set with ruleWithConfiguration():

$rectorConfig->ruleWithConfiguration(RenameMethodCallByNameRector::class, [
    'oldMethod' => 'newMethod',
]);

4.4 Reuse shared logic with a trait

Cross-cutting helpers live in src/Support/. Two examples already in the package:

  • SqlKeywordDetector::isWriteSql(?Arg $arg): bool — classifies a SQL literal as read vs write (used by the DB-routing rules).
  • MytsReceiverDetector::isMytsReceiver(Expr $var): bool — true only for $myts / $GLOBALS['myts'] (used by the receiver-aware MyTS rules).

use the trait in your rule rather than copying the logic.

4.5 Register the rule

Add it to the appropriate set:

// config/sets/xoops.php  (behaviour-preserving)  — or  config/sets/xoops-risky.php
$rectorConfig->rules([
    // …
    \Xoops\Rector\Rules\MyExampleRector::class,
]);

Decide which set. If the rule is behaviour-preserving or fixes outright-broken (removed-function) code → default XOOPS set. If it changes runtime values, escaping, or output → XOOPS_RISKY.

4.6 Test it

The fastest loop is a fixture: a tiny "before" snippet plus a one-rule config file. This works the same on PowerShell and bash (no Unix-only process substitution):

  1. fix.php — the "before":
    <?php
    $x = old_function(1);
  2. rector-fixture.php — a throwaway config enabling just your rule:
    <?php
    use Rector\Config\RectorConfig;
    
    return RectorConfig::configure()
        ->withPaths([__DIR__ . '/fix.php'])
        ->withRules([\Xoops\Rector\Rules\MyExampleRector::class]);
  3. Run it — and run it twice. The second pass must be a no-op (proves your rule is idempotent):
    vendor/bin/rector process --config=rector-fixture.php --dry-run

Then keep the package green:

composer analyse   # PHPStan
composer cs:fix    # PHP-CS-Fixer

The CI workflow (.github/workflows/ci.yml) runs composer validate + CS + PHPStan + an autoload smoke test (every rule class + the set list resolve) across PHP 8.2/8.3/8.4 on every push.