The first time I upgraded a Laravel app from PHP 7.4 to 8.2, I touched 184 files by hand. Most of the changes were the same five patterns repeated. The second time, I let Rector do it and reviewed the diff. Took an afternoon instead of a week.
If you're staring down a Laravel 10 → 13 jump, a PHP version bump, or a long tail of (boolean) casts you keep meaning to clean up, Rector is the tool. It rewrites your code with configurable rule sets, supports Laravel-specific upgrade paths via driftingly/rector-laravel, and slots into CI with --dry-run so reverts get caught at PR time.
What Rector Does (and Doesn't Do)#
Rector is a code transformation tool, not a code style fixer. It rewrites the meaning of your code: renaming methods, replacing deprecated calls, adding return types, swapping array_filter() for a typed collection method. Laravel Pint and Git pre-commit hooks handle whitespace, quote style, and import ordering — those don't change behaviour. Rector does, which is why every Rector PR needs a code review even when the diff looks mechanical.
The other tool to compare it to is PHPStan. PHPStan tells you a type is wrong; Rector fixes it. They work well together, and we'll wire them up later in this guide.
What Rector won't do: it won't rewrite logic you've structured oddly, it won't refactor your service classes into a cleaner architecture, and it won't replace human judgment when a transformation is safe in 99% of cases but breaks the 1%. Treat its output the same way you'd treat a colleague's PR — review it.
Installation and First Run#
Rector targets PHP 7.2+ and the latest stable is 2.4.x. The Laravel extension is driftingly/rector-laravel, which currently requires PHP 8.3+ and Rector 2.4.1 or later. Install both as dev dependencies:
composer require --dev rector/rector driftingly/rector-laravel
Now create rector.php at the project root:
<?php
declare(strict_types=1);
use Rector\Config\RectorConfig;
return RectorConfig::configure()
->withPaths([
__DIR__ . '/app',
__DIR__ . '/config',
__DIR__ . '/database',
__DIR__ . '/routes',
__DIR__ . '/tests',
])
->withSkip([
__DIR__ . '/app/Console/Kernel.php', // legacy file you haven't migrated yet
__DIR__ . '/database/migrations/2014_*', // historical migrations you don't want rewritten
])
->withPhpSets() // applies sets up to your composer.json php version
->withImportNames(); // import FQCNs into use statements
Then run a dry-run before you do anything else:
vendor/bin/rector process --dry-run
--dry-run prints the diff and exits with code 1 if changes would be made. It does not write to disk. Once you're happy with the diff, drop the flag:
vendor/bin/rector process
That's the loop: configure, dry-run, review, apply, commit. The first PR should be small — five files, not five hundred. Rector's own docs are unusually direct about this: "don't apply all rules at once at first; start with 1–3 rules that are easy to integrate and are safe."
Built-in Rule Sets for Laravel#
The Laravel package ships sets keyed to specific Laravel versions plus a stack of code-quality sets. The cleanest setup is the composer-based provider, which reads composer.json and only applies sets relevant to the Laravel version you're upgrading from:
<?php
declare(strict_types=1);
use Rector\Config\RectorConfig;
use RectorLaravel\Set\LaravelSetProvider;
return RectorConfig::configure()
->withPaths([__DIR__ . '/app', __DIR__ . '/tests'])
->withSetProviders(LaravelSetProvider::class)
->withComposerBased(laravel: true);
If you want to be explicit about the target version, use the level set list directly:
use RectorLaravel\Set\LaravelLevelSetList;
return RectorConfig::configure()
->withPaths([__DIR__ . '/app', __DIR__ . '/tests'])
->withSets([
LaravelLevelSetList::UP_TO_LARAVEL_130,
]);
There's also a catalogue of opinionated quality sets you can opt into individually — LARAVEL_CODE_QUALITY, LARAVEL_COLLECTION, LARAVEL_ELOQUENT_MAGIC_METHOD_TO_QUERY_BUILDER, LARAVEL_FACADE_ALIASES_TO_FULL_NAMES, and a dozen more. I add these one at a time after the version upgrade is merged. They produce useful changes — converting Cache::has() ? Cache::get() : default patterns into Cache::get($key, $default), for example — but each one needs its own PR and review pass.
For the actual Laravel 12 → 13 jump, I'd run this alongside the practical Laravel 12 to 13 upgrade guide. The article covers the manual steps Rector can't automate — config-file restructures, environment-variable renames, and the Reverb/passkey gotchas.
PHP Version Upgrade Rules#
Rector's PHP upgrade rules are organised by version. The naive approach — withPhpSets() against a PHP 8.4 project — runs every set from PHP 5.3 onward in a single pass and produces an unreviewable diff. Don't do that. Use withPhpLevel() and walk it up one level at a time:
return RectorConfig::configure()
->withPaths([__DIR__ . '/app'])
->withPhpLevel(0); // start small; bump to 1, 2, 3... as PRs land
Each level adds the next chunk of PHP version rules. Level 0 is the safest pick — return type declarations on simple methods, type widening — and the diff stays reviewable. Once that's merged, bump to level 1, run again, ship the next PR.
If you're already on PHP 8.4 and your composer.json doesn't lie, you can also use the named arguments to apply a single version's rules:
->withPhpSets(php84: true)
This applies only PHP 8.4 rules — useful when you've already cleared earlier versions and want to mop up the remaining mixed types and untyped properties. Pair this with the PHP 8.5 deprecations cheat sheet once you're ready to push toward 8.5; Rector handles most of those automatically.
Writing a Custom Rector Rule#
The shipped rule sets cover framework and language migrations. They don't know about your deprecated OldThing::doStuff() method or your team's habit of calling Carbon::now()->format('Y-m-d') everywhere instead of today(). That's where custom rules earn their keep.
A custom rule is a class that extends Rector\Rector\AbstractRector and implements three methods:
<?php
declare(strict_types=1);
namespace App\Rector;
use PhpParser\Node;
use PhpParser\Node\Expr\StaticCall;
use PhpParser\Node\Identifier;
use Rector\Rector\AbstractRector;
use Symplify\RuleDocGenerator\ValueObject\CodeSample\CodeSample;
use Symplify\RuleDocGenerator\ValueObject\RuleDefinition;
final class CarbonNowFormatToTodayRector extends AbstractRector
{
public function getRuleDefinition(): RuleDefinition
{
return new RuleDefinition(
"Replace Carbon::now()->format('Y-m-d') with today()->toDateString()",
[new CodeSample(
"Carbon::now()->format('Y-m-d');",
'today()->toDateString();'
)]
);
}
/**
* @return array<class-string<Node>>
*/
public function getNodeTypes(): array
{
return [Node\Expr\MethodCall::class];
}
public function refactor(Node $node): ?Node
{
// implementation: detect Carbon::now()->format('Y-m-d')
// and rewrite to today()->toDateString()
// ...
return null; // return null when no change is made
}
}
getNodeTypes() tells Rector which AST node types to feed your rule. refactor() receives one node at a time and returns either a modified node, a new node, or null to skip. getRuleDefinition() is documentation — useful when the rule generator builds your team's rule index.
Register the rule in rector.php:
return RectorConfig::configure()
->withPaths([__DIR__ . '/app'])
->withRules([
\App\Rector\CarbonNowFormatToTodayRector::class,
]);
The rules I write most often are deprecation rules — when we deprecate an internal method, a Rector rule rewrites every call site in one PR rather than chasing them across releases.
CI Integration#
Once Rector is part of your codebase, the question becomes how to keep it that way. CI is where Rector pays for itself — a developer revives a deprecated pattern in a PR, and the build fails before review.
The pattern is to run rector --dry-run in your existing PHP workflow. The dry-run exits non-zero if changes would be made, which is exactly what CI needs. Slot it next to PHPStan in your existing matrix from the GitHub Actions matrix testing guide for Laravel and PHP:
- name: Rector dry-run
run: vendor/bin/rector process --dry-run --no-progress-bar
- name: PHPStan
run: vendor/bin/phpstan analyse --no-progress
Two more things matter for CI runs. First, commit .rector_cache.php to a path your CI cache picks up — Rector's cache makes incremental runs roughly 10x faster. Second, add --clear-cache to the dry-run when CI does a fresh checkout if you don't restore the cache; otherwise stale entries can mask new violations.
For type-aware refactors, point Rector at your existing phpstan.neon so it reads PHPStan's inferred types instead of guessing:
->withPhpStanConfigs([__DIR__ . '/phpstan.neon'])
This is what makes the difference between Rector adding mixed everywhere and Rector adding the correct type. Set up your static analysis first using the PHPStan Level 10 in Laravel guide, then point Rector at the same config.
Gotchas and Edge Cases#
The first run will be huge. If you point Rector at a 5-year-old Laravel codebase with withPhpSets() enabled wholesale, the dry-run output will scroll past your screen for minutes. Resist the urge to apply it all. Limit withPhpLevel(0) or one set at a time, ship a small PR, repeat.
Rector reads composer.json for PHP version detection. If your composer.json says "php": "^7.4" but you're actually deploying on 8.2, Rector applies upgrade rules toward 7.4, not 8.2. Bump the composer constraint first.
Skip generated code and migrations. Database migrations, IDE helper files, and generated factories don't need refactoring. Add them to withSkip() to keep your diffs focused.
Custom rules can break in subtle ways. A rule that rewrites MethodCall nodes will fire on every method call in your codebase. Always test custom rules against a known-good fixture before running them on production code; the writing tests for custom rules guide on the Rector docs is worth the read before you write your second rule.
Rector and Pint can fight. Rector reformats some code in passing — adding parentheses, splitting long lines. Pint may then reformat it back. The fix is to run Rector first, then Pint, in your local workflow and in CI.
Wrapping Up#
The hardest part of adopting Rector isn't the configuration — it's restraint. Run a small set, review the diff, ship. Then run the next set. Don't try to upgrade five years of accumulated PHP and Laravel debt in a single PR.
If you're rolling Rector into a project that's already on 13.x, the next stops are wiring it into the wider Laravel developer toolchain for 2026 and pairing it with PHPStan at level 10. Both compound the value Rector gives you on its own.
FAQ#
What is Rector PHP?
Rector is an open-source PHP refactoring tool that rewrites code automatically based on configurable rule sets. It's used to upgrade PHP versions, migrate framework versions, remove dead code, and apply codebase-wide refactors that would take days to do by hand. It runs natively on PHP 7.2 and higher.
How do I automate Laravel upgrades?
Install driftingly/rector-laravel alongside rector/rector as dev dependencies, then enable the Laravel set provider in rector.php with withSetProviders(LaravelSetProvider::class) and withComposerBased(laravel: true). Rector reads the Laravel version from your composer.json and only applies the rules relevant to your upgrade path. Always run with --dry-run first to preview changes.
Can Rector upgrade PHP syntax automatically?
Yes. Rector has version-specific rule sets for every PHP version from 5.3 through 8.5, covering things like converting array syntax, adding type declarations, replacing deprecated functions, and switching to constructor property promotion. Use withPhpLevel(0) and increment one level at a time rather than running every PHP set in a single pass — the diffs stay reviewable.
Should I run Rector in CI?
Run Rector with --dry-run in CI alongside your other static-analysis tools. Dry-run mode exits non-zero if changes would be made, which fails the build and prevents code regressions from being merged. Cache the .rector_cache.php file so subsequent runs stay fast — without the cache, large codebases can take several minutes per run.
What's the difference between Rector and Laravel Pint?
Rector changes the meaning of code: it renames methods, replaces deprecated calls, and adds type declarations. Laravel Pint changes only formatting: indentation, quote style, import ordering. They're complementary tools — run Rector first to refactor, then Pint to clean up the resulting style. Don't expect either one to replace the other.