← All resources

/// Article

Rector Isn't Just for Upgrades. Most Teams Are Using a Tenth of It.

/// Based on the talk

Rector Beyond Upgrades: Transforming Your Workflow

Laravel Live UK 2026. There's no recording, but the slides are online.

See the slides →

Most PHP teams that use Rector run it once every year or two, when it's time to upgrade PHP or the framework. They point it at an upgrade set, let it rewrite the code, and forget about it until next time.

That's a bit like buying a power drill to hang one picture.

Rector is a general-purpose tool for transforming PHP code. Upgrades are just one thing it can do. It can also make one-off refactors across a whole codebase in seconds, enforce your team's coding standards on every pull request, and even help you work out which code is dead.

And in a world where AI writes more and more of our code, there's one property of Rector that's becoming more valuable, not less. It's deterministic.

Rector in two minutes

Rector rewrites PHP code by transforming its abstract syntax tree (AST). It's built from rules, each of which makes one kind of change, and there are hundreds of them. Rules are grouped into sets, such as "upgrade to PHP 8.4" or "improve code quality".

Install it as a dev dependency:

composer require --dev rector/rector

Run vendor/bin/rector the first time and it'll offer to create a rector.php config for you. A minimal config with one rule looks like this:

use Rector\Config\RectorConfig;
use Rector\Renaming\Rector\MethodCall\RenameMethodRector;
use Rector\Renaming\ValueObject\MethodCallRename;

return RectorConfig::configure()
    ->withPaths([__DIR__ . '/src'])
    ->withConfiguredRule(RenameMethodRector::class, [
        new MethodCallRename(App\Framework\Vehicle::class, 'getName', 'getMake'),
    ]);

Two ways to run it:

vendor/bin/rector --dry-run   # show the diff, change nothing
vendor/bin/rector             # rewrite the files

The dry run shows you a diff for each file, along with the rules that caused each change. Get into the habit of looking at it before you let Rector loose.

Use 1: one-off refactors

Say you need to rename getName() to getMake() on your Vehicle class. Your IDE can probably do that. But what about:

  • Replacing every new DateTime() in your domain code with calls to an injected clock?
  • Converting hundreds of string-based model relationships to class constants?
  • Changing the argument order of a widely used method?

That's where Rector shines. Find (or write) the rule, dry run it, check the diff, apply it. A change that would take a day by hand takes minutes.

Before writing your own rule, search the rule finder. There's a good chance someone has already written it. There are community rulesets too, such as rector-laravel.

Type coverage decides what you can automate

There's one thing that determines how much refactoring you can safely hand to Rector: type information.

function getNames($users)
{
    foreach ($users as $user) {
        $names[] = $user->getUser();
    }
    // ...
}

Rename User::getUser() to User::getUsername(). Should this call change?

Nobody knows, including Rector. $user could be anything with a getUser() method. With 0% type coverage, the refactor can't be automated safely.

/**
 * @param array<int, User> $users
 */
function getNames(array $users): string

Now Rector knows $user is a User, and the rename can be fully automated.

Type information helps PHPStan, Rector, humans, and AI tools alike. On legacy code, adding types in docblocks first is a safe way to get there, because it gives the tools the information without changing how the code runs.

Rector can help here too. Rather than turning on all its type-related rules at once and drowning in changes, use levels to adopt them gradually:

return RectorConfig::configure()
    ->withPaths([__DIR__ . '/src'])
    ->withTypeCoverageLevel(0)
    ->withDeadCodeLevel(0)
    ->withCodeQualityLevel(0);

Bump a level, review the changes, commit, repeat.

Try the prepared sets

If you're not sure where to start, Rector's prepared sets are a good way to see what it can do for your codebase:

return RectorConfig::configure()
    ->withPaths([__DIR__ . '/src'])
    ->withPreparedSets(
        deadCode: true,
        codeQuality: true,
        typeDeclarations: true,
        earlyReturn: true,
    );

Don't apply them all in one go on a big codebase. Dry run them, look at what they'd change, and turn on one at a time. Each one produces a diff that's much easier to review on its own.

Use 2: automate part of code review

This is the use I think most teams are missing.

Every team has coding standards that reviewers keep having to point out. "Use early returns." "Use named arguments here." "Value objects should be final and readonly."

If Rector can make the change, it can also check for it. Run it in CI with --dry-run, and if any rule would change the code, the build fails. Developers run it without --dry-run locally, and the code is fixed for them.

I keep these rules in their own config, separate from anything used for upgrades:

// rector-ci.php
return RectorConfig::configure()
    ->withPaths([__DIR__ . '/app'])
    ->withPreparedSets(earlyReturn: true)
    ->withRules([
        MiddlewareUseNamedArgumentsRector::class,
        ValueObjectsFinalReadonlyRector::class,
    ]);
# In CI
vendor/bin/rector --config rector-ci.php --dry-run

One gotcha: code Rector generates won't always match your formatting standards. Run your code style fixer (PHP CS Fixer, or Laravel Pint on Laravel projects) after Rector.

Pick the right tool for each standard

Not every standard belongs in Rector:

  • Formatting (braces, spacing, PSR-12): a code style fixer.
  • Common patterns (early returns, for example): Rector's prepared sets.
  • Project-specific conventions that can be fixed automatically: a custom Rector rule.
  • Project-specific conventions that need a human to fix: a custom PHPStan rule. (See Stop Writing Coding Standards Documents.)
  • Everything else: written down, and checked in review.

Every time a review comment points out something a tool could have caught, ask which of these it belongs in.

Writing a custom rule

Let's write one. Here's the standard: our codebase has a #[Middleware] attribute, and its optional arguments must always be named.

// Wrong
#[Middleware(RequireToken::class, ['edit'])]

// Right
#[Middleware(RequireToken::class, only: ['edit'])]

Step 1: scaffold it

vendor/bin/rector custom-rule

It asks for a rule name, then generates the rule class, a PHPUnit test, a fixture file, and a config, and adds the namespace to autoload-dev in composer.json. Run composer dump-autoload and you're ready.

Step 2: write the fixtures

Rector tests are fixture based. Each fixture file has the code before, a ----- separator, and the expected code after:

<?php

#[Middleware(RequireToken::class, ['edit'])]
final class EditController {}

-----
<?php

#[Middleware(RequireToken::class, only: ['edit'])]
final class EditController {}

A fixture without a separator asserts that the rule makes no change. These are just as important. A rule is defined as much by what it must leave alone as by what it changes:

<?php

// Already named: leave alone
#[Middleware(RequireToken::class, only: ['edit'])]
final class EditController {}
<?php

// No optional arguments: leave alone
#[Middleware(RequireToken::class)]
final class ListController {}

Step 3: find the node type

Paste the code into the AST explorer. You'll see the attribute is an Attribute node, with a name and a list of args. Each Arg has a nullable name property. If it's null, the argument is positional. If it's set, it's named.

So the rule needs to find Attribute nodes for Middleware, and give names to any positional arguments after the first.

Step 4: write the rule

final class MiddlewareUseNamedArgumentsRector extends AbstractRector
{
    public function getNodeTypes(): array
    {
        return [Attribute::class];
    }

    /**
     * @param Attribute $node
     */
    public function refactor(Node $node): ?Node
    {
        if (!$this->isName($node->name, Middleware::class)) {
            return null;
        }

        $updated = false;

        $second = $node->args[1] ?? null;
        if ($second !== null && $second->name === null) {
            $second->name = new Identifier('only');
            $updated = true;
        }

        $third = $node->args[2] ?? null;
        if ($third !== null && $third->name === null) {
            $third->name = new Identifier('except');
            $updated = true;
        }

        return $updated ? $node : null;
    }
}

The pattern is the same as a PHPStan rule. Bail out with return null as soon as you know the node isn't relevant. Only return the node if you actually changed something.

Run the tests. They pass. Add the rule to rector-ci.php and the standard is enforced.

Step 5: generalise (if it's worth it)

The rule works, but it's hard-coded. If the attribute gains a new parameter, the rule needs updating.

First improvement: read the parameter names from the attribute's constructor, using reflection, instead of hard-coding them:

$parameters = (new ReflectionClass(Middleware::class))
    ->getConstructor()
    ?->getParameters() ?? [];

foreach ($node->args as $index => $arg) {
    if ($index < 1 || $arg->name !== null) {
        continue;
    }
    $parameterName = $parameters[$index]->name ?? null;
    if ($parameterName === null) {
        continue;
    }
    $arg->name = new Identifier($parameterName);
    $updated = true;
}

Second improvement: make the attribute class and the starting index configurable, so one rule can enforce the standard on any attribute:

->withConfiguredRule(AttributeNamedArgsRector::class, [
    new AttributeNamedArgs(Middleware::class, 1),
    new AttributeNamedArgs(AnotherAttribute::class, 0),
])

My advice: solve the specific problem first. Only generalise once you know you need to.

A rule that changes behaviour

Here's another standard: all value objects (marked with a #[ValueObject] attribute) must be final and readonly.

final class ValueObjectsFinalReadonlyRector extends AbstractRector
{
    public function getNodeTypes(): array
    {
        return [Class_::class];
    }

    /**
     * @param Class_ $node
     */
    public function refactor(Node $node): ?Node
    {
        if (!$this->hasValueObjectAttribute($node)) {
            return null;
        }

        if ($node->isFinal() && $node->isReadonly()) {
            return null;
        }

        $node->flags |= Modifiers::FINAL | Modifiers::READONLY;

        return $node;
    }

    private function hasValueObjectAttribute(Class_ $class): bool
    {
        foreach ($class->attrGroups as $attrGroup) {
            foreach ($attrGroup->attrs as $attribute) {
                if ($this->isName($attribute->name, ValueObject::class)) {
                    return true;
                }
            }
        }
        return false;
    }
}

This one comes with a warning. Unlike naming an argument, making a class final changes behaviour. If anything extends a value object, that code was working before the rule ran, and it's broken afterwards.

Rules like this need tests and static analysis behind them. It's also worth asking whether this is really a Rector job. A PHPStan rule that reports the problem, and lets a human decide how to fix it, might be the better fit.

Use 3: investigation work

This is the use that surprises people most. Rector doesn't just change code permanently. It can add temporary instrumentation to help you understand a codebase.

Finding dead code with tombstones

A tombstone is a line added to the top of a method you suspect is dead:

public function calculate(): void
{
    TombstoneReporter::trigger('PriceCalculator', 'calculate');
    // ...
}

Deploy to production. Each time a tombstone fires, it's recorded. After long enough (allowing for anything seasonal or yearly), you know which methods actually run. Tombstones that fired: remove them, the code's alive. Tombstones that never fired: that code is a candidate for deletion.

Adding tombstones to hundreds of methods by hand would be miserable. So don't. Write a Rector rule:

final class AddTombstoneRector extends AbstractRector
{
    public function getNodeTypes(): array
    {
        return [ClassMethod::class];
    }

    /**
     * @param ClassMethod $node
     */
    public function refactor(Node $node): ?Node
    {
        $scope = ScopeFetcher::fetch($node);
        $className = $scope->getClassReflection()?->getName();
        if ($className === null || $node->stmts === null) {
            return null;
        }

        $call = $this->nodeFactory->createStaticCall(TombstoneReporter::class, 'trigger', [
            $this->nodeFactory->createArg($className),
            $this->nodeFactory->createArg($node->name->name),
        ]);

        array_unshift($node->stmts, new Expression($call));

        return $node;
    }
}

(A real version would also skip methods that already have a tombstone.) There's a working tombstone demo on GitHub.

Recording runtime types

The same idea helps with type coverage. For untyped legacy methods, use a rule to inject a call that records what types are actually passed in production:

TypeRecorder::record(method: 'Person::process', argument: 1, value: $name);

Leave it running, look at the data, and add real types based on evidence rather than guesswork.

In both cases, the instrumentation is temporary. Add it with Rector, collect the data, then remove it (with Rector, naturally).

Use 4: downgrades

This one is mostly for library authors, but it's a neat trick.

Say you want to write your library using modern PHP (readonly properties, enums, first-class callable syntax) but some of your users are stuck on an older version. Rector can downgrade code as well as upgrade it:

return RectorConfig::configure()
    ->withPaths([__DIR__ . '/build/src'])
    ->withDowngradeSets(php74: true);

You develop against modern PHP, and your release process runs Rector to produce a version of the code that works on PHP 7.4. Rector itself is distributed this way, and so is PHPStan.

It's not something most application developers need. But it shows how general the tool is: Rector doesn't care which direction the transformation goes.

Rector in the age of AI

It's a fair question. If an AI agent can refactor code, why bother with Rector?

Because Rector is deterministic. The same rule, run on the same code, produces the same result every time. It changes exactly what it's told to change, and nothing else. Ask an AI to make the same change across 500 files and you'll probably get 495 good changes, and five that are subtly different.

For mechanical, repeated transformations, deterministic tools win.

That said, AI and Rector work well together. AI is good at writing Rector rules, especially if you give it fixtures to work from. There are even agent skills for it, such as peterfox/agent-skills.

My approach: keep AI-generated rules in a separate config (say rector-beta.php), away from the rules you trust. Check their output carefully. Once a rule has proved itself, promote it to the main config.

And every custom Rector rule you add to CI is a guardrail for AI-written code too. The agent writes code, Rector checks it, and any drift from your standards gets fixed automatically.

Where to start

If you already use Rector for upgrades:

  1. Create a rector-ci.php with withPreparedSets(earlyReturn: true), and run it with --dry-run in CI.
  2. Look back at your last few code reviews. Find a comment a tool could have made. Search the rule finder, or write a custom rule.
  3. Turn on withTypeCoverageLevel(0) and work up a level at a time.

If you don't use Rector yet, start with an upgrade set for your PHP version, dry run it, and look at the diff. You'll learn a lot about your codebase.

For more detail, the slides are on my website, the exercises from my tutorial Mastering automated refactoring with custom Rector rules are on GitHub, and Rector: The Power of Automated Refactoring by Matthias Noback and Tomas Votruba is the definitive guide.

What's the first coding standard you'd hand over to Rector?

/// About the author

Dave Liddament

Director at Lamp Bristol, writing software commercially for 24+ years. Speaks at international PHP conferences, wrote SARB and PHP Language Extensions, and organises PHP-SW. He teaches this material in our team training workshops.

/// More articles

Your Pull Requests Already Know How Your Project Is Going

Read the article →

PHP Has Had Generics for Years. You're Probably Just Not Using Them.

Read the article →

Stop Writing Coding Standards Documents. Write PHPStan Rules Instead.

Read the article →

Start a conversation

We help teams put these ideas into practice, through training, code review and working alongside you. Tell us where you are and what you're aiming for.