We renamed an internal AuditLog::write() helper eighteen months ago. There were still 340 call sites on the old signature last month, because nobody wants to spend a sprint on find-and-replace and every attempt at sed mangled a multi-line call.
That is a Rector rule. Not a framework-upgrade set — a rule that encodes a decision your team made, that Rector will never ship, and that runs in ten seconds. If you have not wired Rector into the project yet, start with automating Laravel upgrade refactors with Rector and come back; this article assumes rector/rector is already a dev dependency and skips straight to authoring.
The worked example throughout: migrating AuditLog::write($actor, $event, $context) to AuditLog::record($event, $context, actor: $actor). A method rename plus an argument move plus a named argument — enough to be genuinely non-trivial, and small enough to fit in one file.
Pick a refactor that is mechanical enough to automate#
Only reach for a custom Rector rule when you can state the change as "wherever X, make it Y" and the answer does not depend on runtime behaviour. Three criteria: the transformation is syntactically local (you never need to look at another file to know what to do), it is verifiable by your existing test suite, and the before/after are both valid code.
Know which instrument you actually need:
| Tool | What it does | Use it when |
|---|---|---|
| Pint | Reformats | Style only — spacing, ordering, imports |
| PHPStan | Detects and reports | You want CI to fail on a violation |
| Rector | Transforms and rewrites | You want the violation fixed across 2,000 files |
sed / regex |
Corrupts your code | Never, the first time a match spans a line |
PHPStan and Rector are complements, not alternatives. Detect with a custom PHPStan rule so new violations fail the build; remediate the existing 340 with a Rector rule. Both, ideally, in that order.
Bad first candidates: anything that needs a decision per call site, anything where the correct replacement depends on a config value, and anything you cannot describe without the word "usually".
Dump the AST for the code you want to match#
Do not guess node types. Write the shell of the rule, put dump_node() in refactor(), return null, and read the actual class names off the output.
Rector ships two global helper functions for exactly this. print_node() prints a node back to PHP source, so you can confirm what you are looking at; dump_node() prints the AST structure, so you can see the types and child properties.
public function refactor(Node $node): ?Node
{
dump_node($node);
return null; // change nothing while you are exploring
}
Run it against a single file with parallel processing off:
vendor/bin/rector process app/Actions/RefundOrder.php --dry-run --debug
For AuditLog::write($user, 'order.refunded', ['order_id' => $order->id]); you get the shape below (arguments trimmed):
PhpParser\Node\Expr\StaticCall(
class: PhpParser\Node\Name( name: "AuditLog" )
name: PhpParser\Node\Identifier( name: "write" )
args: array(
0: PhpParser\Node\Arg(
name: null
value: PhpParser\Node\Expr\Variable( name: "user" )
byRef: false
unpack: false
)
1: PhpParser\Node\Arg(
name: null
value: PhpParser\Node\Scalar\String_( value: "order.refunded" )
)
2: PhpParser\Node\Arg( ... )
)
)
Three things worth reading off that. The node type is Expr\StaticCall, not MethodCall — those are different classes and matching the wrong one is failure cause number one. The class name appears as written in the source, which is precisely why you must not match it as a string. And Arg carries a nullable name property, which is how PHP-Parser 5 represents named arguments — that is the hook for the last part of our transformation.
--debug also disables parallel processing, which matters here: without it your dumps interleave across worker processes and read as noise.
Scaffold the rule class and declare its node types#
Create the class by hand under utils/rector/src/Rector/. There used to be a generator — vendor/bin/rector custom-rule — and the documentation still mentions it, but in current Rector it prints a deprecation error and generates nothing. It is a two-minute file; write it.
<?php
declare(strict_types=1);
namespace Utils\Rector\Rector;
use App\Support\AuditLog;
use PhpParser\Node;
use PhpParser\Node\Arg;
use PhpParser\Node\Expr\StaticCall;
use PhpParser\Node\Identifier;
use PHPStan\Type\ObjectType;
use Rector\Rector\AbstractRector;
/**
* AuditLog::write($actor, $event, $context)
* => AuditLog::record($event, $context, actor: $actor)
*/
final class AuditLogWriteToRecordRector extends AbstractRector
{
/**
* @return array<class-string<Node>>
*/
public function getNodeTypes(): array
{
return [StaticCall::class];
}
/**
* @param StaticCall $node
*/
public function refactor(Node $node): ?Node
{
return null;
}
}
Two methods. That is the whole contract. If you have read older tutorials you will be looking for getRuleDefinition() returning a RuleDefinition with CodeSample objects — Rector 2 dropped that requirement, because it only ever fed the public rule-search page. A docblock above the class does the same job for your team.
Register the autoload paths in composer.json so both the rule and its tests resolve:
{
"autoload-dev": {
"psr-4": {
"Utils\\Rector\\": "utils/rector/src",
"Utils\\Rector\\Tests\\": "utils/rector/tests"
}
}
}
Then composer dump-autoload. Skipping this produces a "class not found" that looks like a Rector bug for about ten minutes.
Write the fixture test before the transformation logic#
Rector's test harness is the entire development loop, and it is faster than running the rule against your app. A fixture file holds the before-code and the after-code separated by exactly five dashes, and AbstractRectorTestCase asserts the transformed output matches byte for byte.
utils/rector/tests/Rector/AuditLogWriteToRecordRector/Fixture/fixture.php.inc:
<?php
namespace Utils\Rector\Tests\Rector\AuditLogWriteToRecordRector\Fixture;
use App\Support\AuditLog;
class RefundOrder
{
public function handle(User $user, Order $order): void
{
AuditLog::write($user, 'order.refunded', ['order_id' => $order->id]);
}
}
?>
-----
<?php
namespace Utils\Rector\Tests\Rector\AuditLogWriteToRecordRector\Fixture;
use App\Support\AuditLog;
class RefundOrder
{
public function handle(User $user, Order $order): void
{
AuditLog::record('order.refunded', ['order_id' => $order->id], actor: $user);
}
}
?>
The .inc suffix keeps static analysis and your IDE from treating these as real source. A fixture with no separator asserts the opposite — that the rule leaves the code alone:
Fixture/skip_log_write.php.inc:
<?php
namespace Utils\Rector\Tests\Rector\AuditLogWriteToRecordRector\Fixture;
use Illuminate\Support\Facades\Log;
class SkipLogWrite
{
public function handle(): void
{
Log::write('info', 'order.refunded', ['order_id' => 1]);
}
}
?>
That is not a contrived example. Illuminate\Log\Logger::write($level, $message, array $context = []) is a real method with a real facade and exactly three arguments — structurally identical to the call we are rewriting. Without a type check, your rule mangles every logging call in the codebase.
The test class and its config:
<?php
declare(strict_types=1);
namespace Utils\Rector\Tests\Rector\AuditLogWriteToRecordRector;
use Iterator;
use PHPUnit\Framework\Attributes\DataProvider;
use Rector\Testing\PHPUnit\AbstractRectorTestCase;
final class AuditLogWriteToRecordRectorTest 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/configured_rule.php';
}
}
<?php
// utils/rector/tests/Rector/AuditLogWriteToRecordRector/config/configured_rule.php
declare(strict_types=1);
use Rector\Config\RectorConfig;
use Utils\Rector\Rector\AuditLogWriteToRecordRector;
return RectorConfig::configure()
->withRules([
AuditLogWriteToRecordRector::class,
]);
AbstractRectorTestCase extends PHPUnit's TestCase, so it runs in a Pest project without ceremony — Pest requires PHPUnit anyway. Keep these tests in utils/rector/tests rather than tests/, though: your Pest.php binds a base test case and traits like RefreshDatabase per directory, and a Rector rule test wants none of that. Add a separate suite in phpunit.xml and run the loop directly:
vendor/bin/phpunit utils/rector/tests
Red first. Then implement.
Return the node only when you change it#
refactor() has one contract and everybody gets it backwards once. Returning null means "I did not change this node". Returning the node means "changed, re-print this file". A rule that mutates $node and then returns null does nothing at all — no error, no warning, no diff, exit code 0.
// Broken. Runs clean, changes nothing.
public function refactor(Node $node): ?Node
{
if (! $this->isName($node->name, 'write')) {
return null;
}
$node->name = new Identifier('record');
return null; // <-- Rector discards the mutation
}
The fix is the last line. Here is the real body:
/**
* @param StaticCall $node
*/
public function refactor(Node $node): ?Node
{
// Cheapest check first: refactor() runs for every StaticCall in every file.
if (! $this->isName($node->name, 'write')) {
return null;
}
if (count($node->args) !== 3) {
return null;
}
foreach ($node->args as $arg) {
// First-class callable syntax — AuditLog::write(...) — puts a
// VariadicPlaceholder here, not an Arg. ->value would explode.
if (! $arg instanceof Arg) {
return null;
}
// Named or spread args mean the positions are not what we assume.
if ($arg->name instanceof Identifier || $arg->unpack) {
return null;
}
}
if (! $this->isObjectType($node->class, new ObjectType(AuditLog::class))) {
return null;
}
[$actor, $event, $context] = $node->args;
$actor->name = new Identifier('actor');
$node->name = new Identifier('record');
$node->args = [$event, $context, $actor];
return $node;
}
For the cases the return type does not cover: return \PhpParser\NodeVisitor::REMOVE_NODE to delete the node, or an array of nodes to replace one statement with several.
One more thing that bites on heavier rewrites — comments and formatting live on the node's attributes, so rebuilding a node from scratch drops the docblock above it. Prefer surgical mutation, as above, over new StaticCall(...). When you must construct, mirrorComments() on AbstractRector copies them across.
Narrow the match so the rule cannot fire on the wrong code#
A rule that fires too broadly is worse than no rule, because you will not notice until code review and you will not trust the tool again. Work outward from the cheapest, most specific check.
Start too broad and watch it fail:
[StaticCall::class]alone visits every static call in the codebase.refactor()runs thousands of times.- Add
isName($node->name, 'write')and you have narrowed to::write()— which still catchesLog::write(),Csv::write(), and every stream wrapper someone wrapped in a facade. - Match the class as a string (
$this->getName($node->class) === 'AuditLog') and you break onuse App\Support\AuditLog as Audit;, on fully-qualified call sites, and on any subclass.
isObjectType() is the one that holds. It goes through Rector's type resolver rather than the literal text, so aliases, imports and inheritance all resolve to the same ObjectType. AbstractRector gives you isName(), isNames(), getName(), isObjectType(), getType() and getNativeType() — reach for the type-aware ones whenever the answer is "it depends what class this actually is".
Which is where the sharpest gotcha lives: Rector resolves types through PHPStan, and it inherits PHPStan's autoloading assumptions. If it cannot resolve App\Support\AuditLog — wrong autoload config, a class outside your withPaths() — isObjectType() returns false and your rule matches nothing, silently. If the fixture test passes and the real run finds zero call sites, suspect autoloading before you suspect your logic.
Register the rule and run it in dry-run mode#
Add the rule to rector.php:
<?php
declare(strict_types=1);
use Rector\Config\RectorConfig;
use Utils\Rector\Rector\AuditLogWriteToRecordRector;
return RectorConfig::configure()
->withPaths([
__DIR__ . '/app',
__DIR__ . '/tests',
])
->withRules([
AuditLogWriteToRecordRector::class,
]);
Preview before you touch anything, and isolate the new rule so its diff is not buried under 350 upgrade rules:
vendor/bin/rector process app --dry-run \
--only="Utils\Rector\Rector\AuditLogWriteToRecordRector"
--only requires the rule to already be registered in rector.php or a loaded set — it filters what runs, it does not add rules. Mind the quotes; the backslashes need them on every platform.
If a change to the rule appears to have no effect, it is the cache:
vendor/bin/rector process app --dry-run --clear-cache
Apply it in reviewable slices and gate it in CI#
Run the rule per directory and commit in slices a human can actually read. Put the rule itself and its fixtures in the first commit, alone, so a reviewer reads the intent once instead of inferring it from 340 diff hunks.
vendor/bin/rector process app/Actions --only="Utils\Rector\Rector\AuditLogWriteToRecordRector"
vendor/bin/pint --dirty
Rector then Pint, never the reverse — Rector's printer emits valid but unformatted code around the nodes it rewrites, and Pint normalises it. If you run Pint, PHPStan and Rector through Duster that ordering is already handled; if you drive Pint from a shared config across projects, make sure the Rector step lands first in the composer script.
Then keep the rule permanently, in --dry-run mode, as a build gate:
- name: Rector — fail on any proposed change
run: vendor/bin/rector process --dry-run
--dry-run exits non-zero when it wants to change something, so a new AuditLog::write() call fails the PR. That is the use of Rector nobody mentions: not a one-off migration, but a standing guarantee that the convention cannot regress.
Keep codebase-specific rules in utils/rector/ inside the app. Extract to a package only when a second repository needs the same rule — a shared package you maintain for one consumer is pure overhead. If the backlog you are working through is larger than one convention, the PHPStan baseline burn-down approach is the right way to sequence it, and prepared sets still handle the framework side of a Laravel 12 to 13 upgrade better than anything you would write yourself.
FAQ#
How do I write a custom Rector rule?
Create a class extending Rector\Rector\AbstractRector and implement two methods. getNodeTypes() returns an array of PHP-Parser node classes to visit, such as [StaticCall::class]. refactor(Node $node): ?Node receives each matching node and returns the modified node when it changed something, or null to leave the file untouched. Register the class in rector.php with ->withRules([YourRector::class]), then preview with vendor/bin/rector process app --dry-run.
What is the difference between getNodeTypes and refactor in Rector?
getNodeTypes() is the filter and refactor() is the work. Rector walks the abstract syntax tree of every file in your configured paths and calls refactor() only for nodes whose class appears in the getNodeTypes() array. Getting the node type wrong means refactor() is never called at all, which is why Expr\StaticCall versus Expr\MethodCall matters so much. Because refactor() runs once per matching node per file, put your cheapest checks at the top of it.
Why is my custom Rector rule not changing anything?
There are three usual causes, in order of likelihood. You returned null after mutating the node — Rector reads null as "no change" and discards your edit, so return $node instead. You declared the wrong node type, so refactor() never runs. Or a type check like isObjectType() cannot resolve the class because of autoloading, so every node is skipped silently. Add dump_node($node) at the top of refactor() with --debug: if nothing prints, it is the node type or the paths; if it prints and nothing changes, it is your return value.
How do I test a custom Rector rule?
Use the fixture harness that ships with rector/rector. Write a test class extending Rector\Testing\PHPUnit\AbstractRectorTestCase that yields files from a Fixture/ directory and points provideConfigFilePath() at a small rector.php-style config registering only your rule. Each fixture is a .php.inc file holding the before-code and the after-code separated by exactly five dashes, and the harness asserts the transformation matches exactly. A fixture with no separator asserts that your rule makes no change, which is how you pin down false positives.
How do I find the right PHP-Parser node type for my code?
Put dump_node($node) inside refactor(), return null, and run Rector against one file with --dry-run --debug. Rector ships dump_node() and print_node() as global helpers precisely for this — dump_node() prints the node's class and child properties, print_node() prints it back as PHP source. Reading the real output beats guessing from the node reference, because the node you want is often one level up or down from the one you assumed.
Should I use Rector or PHPStan to enforce a convention?
Both, for different halves of the job. PHPStan detects and reports, so it belongs in CI as the gate that stops new violations reaching main. Rector transforms, so it belongs in the pull request that clears the violations you already have. The practical sequence is to write the PHPStan rule first to find out how many call sites exist, write the Rector rule to fix them, then leave the PHPStan rule running forever — or run Rector itself in --dry-run mode in CI, which gives you the same gate from one piece of code.