zero-to-prod / laravel-rector
Opinionated Rector Rules for Laravel
Requires
- php: ^8.5
- ext-tokenizer: *
- illuminate/support: ^13.0
- nikic/php-parser: ^5.8
- phpstan/phpstan: ^2.2
- rector/rector: ^2.5
- symplify/rule-doc-generator-contracts: ^11.2
Requires (Dev)
- laravel/mcp: ^0.9
- laravel/pint: ^1.30
- orchestra/testbench: ^11.0
- pestphp/pest: ^5.0
- pestphp/pest-plugin-rector: ^5.0
Suggests
- laravel/mcp: Required by the MCP server that exposes this package's documentation to coding agents.
Provides
None
Conflicts
None
Replaces
None
README
Opinionated Rector Rules for Laravel
Requirements
Installation
composer require zero-to-prod/laravel-rector
Configuration
CLI install. It asks for every value the package can be configured with and
writes config/laravel-rector.php:
php artisan laravel-rector:install
Rerunning it is safe: the file reports created, unchanged or updated, and
is only overwritten once you confirm.
To publish the configuration file by itself instead:
php artisan vendor:publish --tag=laravel-rector-config
Rules
AddReadonlyToClassWithTraitRector— Declare a class readonly when it uses a configured traitAddTypeToConstOnReadonlyClassRector— Add type to constants on readonly classes regardless of finalCollapseSingleLineDocblockRector— Write a docblock saying one thing on one lineEnforceControllerSuffixRector— Controllers must be named with a Controller suffixEnforceInvokableControllerRector— Controllers must be readonly and invokable, declaring __invoke and no other public methodEnforceInvokableControllerRouteRector— Routes must map to an invokable controller class, never to a methodEnforceRegisteredClassRector— Classes of a registered kind must be named by their registryForbidAttributedClassDependencyRector— Classes carrying a configured attribute must not name another class carrying itForbidBladeAttributeValueRector— Blade templates must not write a forbidden value in a configured attributeForbidClassDependencyRector— Namespaces must not name a class a configured direction forbids themForbidClassUsageRector— Statements must not name a configured classForbidCommentPhraseRector— Comments must not carry a configured phraseForbidDuplicateBladeElementRector— Blade templates must write a configured element onceForbidKeywordUsageRector— PHP files must not use a configured keywordForbidNamespaceDependencyRector— Namespaces must not name a namespace a configured direction forbids themRenameParamToMatchTypeExactCaseRector— Rename param to match class type hint exactly (PascalCase)
Register the rules you want in rector.php:
use Rector\Config\RectorConfig; use ZeroToProd\LaravelRector\Rector\AddReadonlyToClassWithTraitRector; use ZeroToProd\LaravelRector\Rector\AddTypeToConstOnReadonlyClassRector; use ZeroToProd\LaravelRector\Rector\CollapseSingleLineDocblockRector; use ZeroToProd\LaravelRector\Rector\EnforceControllerSuffixRector; use ZeroToProd\LaravelRector\Rector\EnforceInvokableControllerRector; use ZeroToProd\LaravelRector\Rector\EnforceInvokableControllerRouteRector; use ZeroToProd\LaravelRector\Rector\EnforceRegisteredClassRector; use ZeroToProd\LaravelRector\Rector\ForbidAttributedClassDependencyRector; use ZeroToProd\LaravelRector\Rector\ForbidBladeAttributeValueRector; use ZeroToProd\LaravelRector\Rector\ForbidClassDependencyRector; use ZeroToProd\LaravelRector\Rector\ForbidClassUsageRector; use ZeroToProd\LaravelRector\Rector\ForbidCommentPhraseRector; use ZeroToProd\LaravelRector\Rector\ForbidDuplicateBladeElementRector; use ZeroToProd\LaravelRector\Rector\ForbidKeywordUsageRector; use ZeroToProd\LaravelRector\Rector\ForbidNamespaceDependencyRector; use ZeroToProd\LaravelRector\Rector\RenameParamToMatchTypeExactCaseRector; return RectorConfig::configure() ->withPaths([ __DIR__.'/app', __DIR__.'/routes', __DIR__.'/tests', ]) ->withRules([ AddReadonlyToClassWithTraitRector::class, AddTypeToConstOnReadonlyClassRector::class, CollapseSingleLineDocblockRector::class, EnforceControllerSuffixRector::class, EnforceInvokableControllerRector::class, EnforceInvokableControllerRouteRector::class, EnforceRegisteredClassRector::class, ForbidAttributedClassDependencyRector::class, ForbidBladeAttributeValueRector::class, ForbidClassDependencyRector::class, ForbidClassUsageRector::class, ForbidCommentPhraseRector::class, ForbidDuplicateBladeElementRector::class, ForbidKeywordUsageRector::class, ForbidNamespaceDependencyRector::class, RenameParamToMatchTypeExactCaseRector::class, ]);
AddReadonlyToClassWithTraitRector
A trait can say what a class is. A class using App\Helpers\DataModel is a
data model: it is handed its values and changes none of them, so it is
declared readonly.
Which traits say so is yours to name, with traits. A class using one of them
and not declared readonly is declared readonly, and a class using none of them
is left alone. The trait has to be used by the class itself: a trait reached
through another trait or through a parent is not written in the file being
read.
A class PHP would refuse to declare readonly is left alone rather than broken: one declaring a property that is static, untyped, or given a default, and one that is abstract or extends another class, where the classes either side of it decide too.
Configured with:
->withConfiguredRule(AddReadonlyToClassWithTraitRector::class, [ 'traits' => [ 'App\\Helpers\\DataModel', ], ])
-class User +readonly class User { use DataModel; public string $name; }
Configured with:
->withConfiguredRule(AddReadonlyToClassWithTraitRector::class, [ 'traits' => [ 'App\\Helpers\\DataModel', ], 'leave_todo' => true, ])
+// TODO: declare this class readonly: it uses App\Helpers\DataModel
class User
{
use DataModel;
public string $name;
}
AddTypeToConstOnReadonlyClassRector
Constants on a readonly class carry a type, whether the class is final or not.
A constant a parent already declares is left alone: the type it is given there is the one that counts.
readonly class SomeModel
{
- public const name = 'name';
+ public const string name = 'name';
}
Configured with:
->withConfiguredRule(AddTypeToConstOnReadonlyClassRector::class, [ 'leave_todo' => true, ])
readonly class SomeModel
{
+ // TODO: type this constant as string
public const name = 'name';
}
CollapseSingleLineDocblockRector
A docblock saying one thing is written on one line, so the three lines it used to take become the one line it needs.
A docblock saying more than one thing is left as it is written: the moment a second line carries anything at all, the shape of the block is the reader's own, and collapsing it would be a rewrite rather than a tidy.
The line is the docblock's only content, whichever line it was written on, so both a block opening on its own line and one opening on the content's line collapse the same way.
-/** - * @throws ReflectionException - */ +/** @throws ReflectionException */ public function handle(): void
Configured with:
->withConfiguredRule(CollapseSingleLineDocblockRector::class, [ 'leave_todo' => true, ])
/**
* @throws ReflectionException
*/
+// TODO: write this docblock on one line
public function handle(): void
EnforceControllerSuffixRector
A controller says so in its name: the class a route maps to ends in
Controller, so the class behind GET /user is UserShowController.
The application's own routes decide what a controller is. The rule asks the router what every registered route maps to, booting the application to do it, so a class is held to the convention because a request reaches it rather than because of where it is filed. A class no route maps to is left alone, and so is a route mapping to a closure: it names no class to hold to anything.
Renaming a class moves every reference to it — the route, the tests, the container bindings — and none of them is in the file that declares it, so there is nothing here to rewrite. The class is reported as an error naming the file and line, and the rename is yours to make.
The application is booted from the directory Rector was run in, which is the
application root. Configured with base_path, it is booted from there
instead.
-// Route::get('/user', UserShow::class); +// Route::get('/user', UserShowController::class); -readonly class UserShow +readonly class UserShowController { public function __invoke(User $User): View { return view('user.show', ['user' => $User]); } }
Configured with:
->withConfiguredRule(EnforceControllerSuffixRector::class, [ 'leave_todo' => true, ])
// Route::get('/user', UserShow::class);
+// TODO: Class "UserShow" is the controller for route "GET /user" and does not end in Controller. Rename it UserShowController.
readonly class UserShow
{
public function __invoke(User $User): View
{
return view('user.show', ['user' => $User]);
}
}
EnforceInvokableControllerRector
A controller is one readonly action: it declares __invoke, nothing else
public, and nothing about itself it can change.
A class whose name ends in Controller is held to it. Every other public
method is an action hiding in a class that already has one, and there is
nothing to rewrite it to — where it belongs is a controller of its own,
named for what it does. So each one is reported as an error naming the file
and line, as is a controller declaring no public __invoke at all, and one
not declared readonly: an action holds the dependencies it was handed and
changes nothing about itself between being constructed and being called.
A constructor, a static middleware() declared for Laravel's HasMiddleware,
and any method that is not public are left alone: none of them is reachable as
a route action. An abstract class is left alone too — a base controller
routes to nothing.
Configured with require_readonly set to false, how a controller is declared
stops being the rule's business and only the invokable half is enforced.
-class UserController +readonly class UserShowController { - public function show(User $User): View + public function __invoke(User $User): View { return view('user.show', ['user' => $User]); } }
Configured with:
->withConfiguredRule(EnforceInvokableControllerRector::class, [ 'leave_todo' => true, ])
readonly class UserController
{
public function __invoke(): View
{
return view('user.index');
}
+ // TODO: Controller declares public method "show". Controllers are invokable: move it to a controller of its own, named __invoke.
public function show(User $User): View
{
return view('user.show', ['user' => $User]);
}
}
Configured with:
->withConfiguredRule(EnforceInvokableControllerRector::class, [ 'require_readonly' => false, ])
-class UserController +class UserShowController { - public function show(User $User): View + public function __invoke(User $User): View { return view('user.show', ['user' => $User]); } }
EnforceInvokableControllerRouteRector
Controllers are invokable: a route maps to a class, never to a method on one.
[Controller::class, '__invoke'] is the same route written the long way, so
it is rewritten to Controller::class. Every other action that names a method
— an array callable, an @ string, Route::resource(),
Route::controller() — has no invokable equivalent to rewrite it to and is
reported as an error instead.
-Route::get('/user', [UserShowController::class, '__invoke']); +Route::get('/user', UserShowController::class);
Configured with:
->withConfiguredRule(EnforceInvokableControllerRouteRector::class, [ 'leave_todo' => true, ])
+// TODO: Route action names __invoke. Pass the controller class itself.
Route::get('/user', [UserShowController::class, '__invoke']);
EnforceRegisteredClassRector
A registry is a promise that everything of a kind is named in one place: the plugins the application installs, the handlers a dispatcher reaches, the indexes a page reads. What breaks the promise is never the registry — it is the class written to be registered and left out, which compiles, tests green from its own test, and is simply never reached.
Which kinds those are is yours to name, with registries: the class
registration is declared on, and what it registers — a contract the class
declares with implements, a namespace it is filed under, a suffix it is
named with. A class is held to being registered when every criterion given
holds for it, so one criterion is a wide net and three is a narrow one, and an
entry giving none is refused as the configuration is read.
A namespace is written plainly to mean itself, or with a trailing \* to mean
everything below it. Names are compared as PHP compares them, without regard
to case or a leading slash. What a class declares is read from the
declaration, so a contract it reaches through a parent is seen only where the
parent itself is what implements names.
The registry is read by reflection, which is what lets one file answer for
another: its enum cases, its constants and its default property values are
gathered, arrays among them flattened, and every string found is a
registration. So a registry declares what it holds however it likes — a
backed enum of class strings, a const array, the property a server lists its
tools on — and a class named anywhere among them is registered.
There is nothing to rewrite an unregistered class to, since where it belongs
on the registry is the registry's business, so every one is reported as an
error naming the class, the registry and the line. Configured with
leave_todo, the rule leaves that sentence as a comment on the class instead.
Configured with:
->withConfiguredRule(EnforceRegisteredClassRector::class, [ 'registries' => [ [ 'registry' => 'App\\Plugins\\PluginIndex', 'implements' => 'App\\Plugins\\DescribesPlugin', 'namespace' => 'App\\Plugins\\*', ], ], ])
enum PluginIndex: string
{
case adminLink = AdminLinkPlugin::class;
+ case sitemap = SitemapPlugin::class;
}
final readonly class SitemapPlugin implements DescribesPlugin
{
}
ForbidAttributedClassDependencyRector
Some kinds of class are defined by being flat. An effect that performs exactly one side effect stops being atomic the moment it calls another effect; an action that orchestrates effects stops being the single place branching lives the moment it calls another action. Both read as a convenience at the moment they are written, both type-check, and neither is caught by anything but review.
Which kinds those are is yours to name, with attributes: a list of the
attributes that mark a class as one of them. A class carrying a configured
attribute must not name another class carrying that same attribute. The check
is pairwise and per attribute, never across two of them, so an #[Action]
naming an #[Effect] is the direction the layering is for and is left alone.
A configuration naming no attribute is refused as it is read.
Membership is the attribute, not the namespace, which is the whole reason this
rule exists rather than a direction on ForbidNamespaceDependencyRector: an
effect, the effects it may not name and the #[Effect] attribute marking all
of them are filed under one namespace, so forbidding that namespace itself
would forbid every effect from naming the attribute that makes it one. Here
the attribute class is simply never a match — what marks a class #[Effect]
is not itself marked #[Effect].
What a class declares is read from the declaration, so an attribute it inherits from a parent does not make it one of the kind. The declaration a statement is written in is the one it is read against, and a statement written before any of them — an import — is read against the first declaration the file carries one on. What a named class declares is read by reflection, so a class the analysis cannot resolve — unautoloadable, or not there at all — carries nothing and is left alone. Only a file whose class carries a configured attribute is reflected against at all.
A statement names a class however PHP lets it: an import, a parent, an
interface, an attribute, a type, a new, a static call. The name is read as
resolved, so the short name an import brought in and the fully qualified one
are the same class. A class naming itself — self, static, or its own
name written out — names nothing new.
There is nothing to rewrite a forbidden dependency to — the fix is to hoist
the call into the layer that orchestrates, which is a decision — so every
statement naming one is reported as an error naming both classes, the
attribute they share and the line. Configured with leave_todo, the rule
leaves that sentence as a comment where it found it instead.
Configured with:
->withConfiguredRule(ForbidAttributedClassDependencyRector::class, [ 'attributes' => [ 'App\\Domain\\Effect\\Effect', 'App\\Domain\\Action\\Action', ], ])
namespace App\Domain\Effect;
#[Effect]
final readonly class ChargeCard
{
public function __invoke(Order $Order): void
{
- new SendReceipt()($Order);
}
}
ForbidBladeAttributeValueRector
An attribute value written by hand is a value a refactoring cannot follow: a
path typed into an href, a route spelled out in a form's action, each of
them a link still pointing where the application no longer answers.
Which values those are is yours to name, with attributes: a pattern the
value must not match, keyed by the attribute it is forbidden in. A pattern
PCRE cannot compile is refused as the rule is configured, naming the reason,
rather than quietly matching nothing.
An attribute is matched the way HTML reads its name: without regard to case,
and only where the whole name is written, so href is not found in
data-href, in :href or in x-bind:href — an attribute bound to an
expression is already an expression. The value is read however it is written,
in double quotes, in single quotes or in neither, and the pattern is matched
against the value alone.
Only Blade templates are read, the files named *.blade.php. A value written
inside a Blade comment or an HTML comment is not written on the page, so it is
not read.
There is nothing to rewrite a forbidden value to, so a template writing one is reported as an error naming the attribute, the value, the pattern that forbids it and the line it is written on.
Configured with leave_todo, the rule reports nothing: a template renders
what it says, and a note left in one is a note the page would carry.
Configured with:
->withConfiguredRule(ForbidBladeAttributeValueRector::class, [ 'attributes' => [ 'href' => '#^/#', ], ])
-<a href="/home">Home</a> +<a href="{{ route('home') }}">Home</a>
ForbidClassDependencyRector
A mechanism a project has centralized — reflection, direct file access, a raw HTTP client — is a mechanism only the code that owns it should reach for. Everywhere else, reaching for it directly is a shortcut around whatever the owner enforces on the way: a plugin doing its own reflection instead of asking the host that already swept it, a domain object opening a file instead of asking the disk abstraction the rest of the codebase agreed on. It type-checks and it works, which is exactly why nothing but a rule catches it.
This is ForbidNamespaceDependencyRector's sibling, forbidding by the same
shape and the same dependencies/except configuration, but at a different
granularity: there, a forbidden target is a namespace, and a used class
violates it by where it is declared. Here, a forbidden target is the class
itself — named exactly, or by a trailing * naming a family that shares no
namespace to forbid instead. PHP's own reflection classes are the case this
exists for: ReflectionClass, ReflectionMethod, ReflectionEnumBackedCase
and the rest are all declared globally, so no namespace pattern reaches them,
and Reflection* does — matched, like every pattern here, against the used
class's own full, resolved name.
Read ForbidNamespaceDependencyRector for what dependencies and except
mean, how a statement is found to name a class, and what happens when it does.
The one difference is this: there, App\Http\* is read against a used class's
namespace; here, the identical pattern is read against the used class's full
name instead — which still means "every class under App\Http," since every
one of them has a full name starting with App\Http\, and additionally lets a
bare prefix with no namespace separator, Reflection*, reach a family that
was never inside a namespace to begin with.
Configured with:
->withConfiguredRule(ForbidClassDependencyRector::class, [ 'dependencies' => [ 'App\\Plugins\\*' => [ 'Reflection*', ], ], 'except' => [ 'App\\Plugins\\RouteTags\\LegacyBridge', ], ])
namespace App\Plugins\AdminLink;
final class AdminLinkPlugin
{
- public function tagged(string $enum, string $case): array
+ public function tagged(TaggedRoute $TaggedRoute): array
{
- return new ReflectionEnumBackedCase($enum, $case)->getAttributes();
+ return $TaggedRoute->attributes;
}
}
ForbidClassUsageRector
A class a project has decided against is a class no file should name: a facade it is moving off, a helper a rewrite replaced, a package class it no longer wants reached directly.
Which classes those are is yours to name, with classes. There is nothing to
rewrite a forbidden class to, so the rule never changes the code: every
statement naming one carries a comment saying so instead, and running twice
leaves one comment rather than two.
A statement names a class however PHP lets it: an import, a parent, an
interface, an attribute, a type, a new, a static call. The name is read as
resolved, so the short name an import brought in and the fully qualified one
are the same class. A statement nested in another waits its own turn, so the
comment lands on the line the name is written on.
Configured with leave_todo, nothing changes: the comment is all this rule
ever leaves.
Configured with:
->withConfiguredRule(ForbidClassUsageRector::class, [ 'classes' => [ 'Illuminate\\Support\\Facades\\DB', ], ])
+// TODO: do not use Illuminate\Support\Facades\DB
$user = DB::table('users')->first();
ForbidCommentPhraseRector
A comment a project has decided against is a comment no file should carry: a note left for nobody, a slur, a ticket number the tracker no longer knows, a name a rewrite retired.
Which phrases those are is yours to name, with phrases. A phrase written as
a delimited pattern, such as /fixme/i, is matched as a regular expression;
every other phrase is matched as text, without regard to case. A pattern PCRE
cannot compile is refused as the rule is configured, naming the reason, rather
than quietly matching nothing.
There is nothing to rewrite a phrase to, so every comment carrying one is reported as an error naming the phrase, the comment and the line it is written on.
The phrases are read from the file's comment tokens, in a line comment, a hash comment or a docblock, so one written inside a string or a heredoc is not a violation.
Configured with leave_todo, the rule reports nothing at all: the note it
would leave is the comment it just found.
Configured with:
->withConfiguredRule(ForbidCommentPhraseRector::class, [ 'phrases' => [ '/fixme/i', ], ])
-// FIXME the empty case -return $items[0]; +return $items[0] ?? null;
ForbidDuplicateBladeElementRector
An element a page is allowed one of is an element a template must write once:
a second <title>, a second <h1>, a second <x-layout>, each of them a
page saying two things where the browser reads one.
Which elements those are is yours to name, with elements. A name is written
as the tag is, title or x-layout, and is matched the way HTML reads a tag
name: without regard to case, and only where the whole name is written, so
<title> is not found in <titlebar>.
Only Blade templates are read, the files named *.blade.php, and only their
opening tags count: a closing tag is the same element, written again. An
element written inside a Blade comment or an HTML comment is not written on
the page, so it is not counted.
There is nothing to rewrite a second element to, so a template writing one is reported as an error naming the element, the number of times it is written and the lines it is written on.
Configured with leave_todo, the rule reports nothing: a template renders
what it says, and a note left in one is a note the page would carry.
Configured with:
->withConfiguredRule(ForbidDuplicateBladeElementRector::class, [ 'elements' => [ 'title', ], ])
-<title>@yield('title')</title> -<title>Dashboard</title> +<title>@yield('title', 'Dashboard')</title>
ForbidKeywordUsageRector
A PHP keyword a project has decided against is a language construct no file
should use: a switch whose cases should be behavior, a match whose
branches should be named decisions.
Which keywords those are is yours to name, with keywords. They are matched
the way PHP reads them, without regard to case, and only as PHP tokens, so a
word in a comment, string, heredoc or identifier is not a violation. A
configured word PHP does not reserve as a keyword is refused rather than
quietly matching nothing.
There is no generally correct rewrite for a forbidden language construct, so every use is reported as an error naming the keyword and the line it is written on.
Configured with leave_todo, the rule leaves a comment on the closest
statement carrying the keyword instead of reporting an error. Set
todo_comment to replace the complete comment, including its TODO keyword.
Running twice leaves one comment rather than two.
Configured with:
->withConfiguredRule(ForbidKeywordUsageRector::class, [ 'keywords' => [ 'switch', ], ])
-switch ($status) { - case 'paid': - return true; - default: - return false; -} +return $status === 'paid';
Configured with:
->withConfiguredRule(ForbidKeywordUsageRector::class, [ 'keywords' => [ 'match', ], 'leave_todo' => true, 'todo_comment' => '// FIXME: replace this match expression', ])
+// FIXME: replace this match expression
return match ($status) {
'paid' => true,
default => false,
};
ForbidNamespaceDependencyRector
A dependency has a direction, and the direction is what a layer, a module or a package is: a domain does not reach for the delivery mechanism that calls it, a host declaring a contract does not know what implements it, and a package's root does not name the directories filed beneath it. PHP says none of that, and every one of those inversions type-checks, passes its tests, and reads as a convenience at the moment it is written.
Which directions those are is yours to name, with dependencies: a namespace,
keyed to the namespaces it must not name. except names the classes no
direction applies to, which is what a registry is — the one place allowed to
know both sides.
A name is written plainly, App\Domain, to mean itself, or with a trailing
\*, App\Domain\*, to mean everything below it. What "below" counts is what
the name is read against: a direction is read against the namespace a file
declares, so App\Domain\* is every namespace under it and not App\Domain
itself — which is how a package's root is forbidden its own children while
the files sharing that root go on naming each other. An exception is read
against a class, so App\Plugins\* is every class under App\Plugins. Names
are compared as PHP compares them, without regard to case or a leading slash.
A statement names a namespace however PHP lets it: an import, a parent, an
interface, an attribute, a type, a new, a static call. The name is read as
resolved, so the short name an import brought in and the fully qualified one
are the same class. A statement nested in another waits its own turn, so the
violation is reported against the line the name is written on.
There is nothing to rewrite a forbidden dependency to — the fix is an
interface, an inversion or a registry, and which of the three is a decision
— so every statement naming one is reported as an error naming both
namespaces and the line. Configured with leave_todo, the rule leaves that
sentence as a comment where it found it instead.
A forbidden target here is a namespace: the check is what the used class's own
namespace matches. Forbidding a specific class, or a family of classes sharing
a name rather than a namespace — PHP's own Reflection*, all declared
globally — is ForbidClassDependencyRector.
Configured with:
->withConfiguredRule(ForbidNamespaceDependencyRector::class, [ 'dependencies' => [ 'App\\Domain\\*' => [ 'App\\Http\\*', ], ], 'except' => [ 'App\\Domain\\Registry', ], ])
namespace App\Domain\Billing; -use App\Http\Controllers\InvoiceController; - final class Invoice { - public function url(): string + public function url(GeneratesInvoiceUrls $GeneratesInvoiceUrls): string { - return InvoiceController::urlFor($this); + return $GeneratesInvoiceUrls->for($this); } }
RenameParamToMatchTypeExactCaseRector
A parameter typed with a class is named after that class, in the class's own casing.
Methods that override a parent or interface declaration are left alone: their parameter names are part of a contract this rule has no business rewriting.
final class SomeClass
{
- public function run(Apple $pie)
+ public function run(Apple $Apple)
{
- $food = $pie;
+ $food = $Apple;
}
}
Configured with:
->withConfiguredRule(RenameParamToMatchTypeExactCaseRector::class, [ 'leave_todo' => true, ])
final class SomeClass
{
+ // TODO: rename $pie to $Apple, after its type
public function run(Apple $pie)
{
$food = $pie;
}
}
Options
Every rule takes one option, leave_todo. Configured with it, a rule stops
changing the code and stops reporting an error: it leaves a comment naming the
violation where it found it, so the change stays a decision for whoever reads
the file.
->withConfiguredRule(EnforceInvokableControllerRouteRector::class, [ EnforceInvokableControllerRouteRector::LEAVE_TODO => true, ])
The comment is left on the statement the violation sits on, and running twice
leaves one comment rather than two, so the option is safe to run in a loop
while the todos are worked off. ForbidTodoAnnotationRector is the exception
that proves the rule: configured this way it reports nothing at all, because
the note it would leave is the comment it just found.
Agent development
The package registers an MCP server so
coding agents can read how it is meant to be used. It requires
laravel/mcp, and registers nothing without
it.
composer require --dev laravel/mcp php artisan mcp:start laravel-rector
Register it with your agent:
claude mcp add laravel-rector -- php artisan mcp:start laravel-rector
Four tools are exposed:
readme— this document.rules— every rule the package ships: what it does, the code it rewrites and how to register it, read from the rules themselves rather than written out here. The same content as the Rules section above.api— the exact signature of every public class, property and method. Anything unlisted is internal and may change in any release.install— whatlaravel-rector:installdoes, without a prompt to answer. Takesenabledandhandle, each defaulting to the current setting, and writesconfig/laravel-rector.php. A file that already says something else is left alone and reported until the call passesoverwrite: true.
Point the handle somewhere else, or turn the server off, in
config/laravel-rector.php:
'mcp' => [ 'enabled' => true, 'handle' => 'laravel-rector', ],
Development
composer check # lint, rector, phpstan, docs, 100% coverage, bc-check — mutates nothing composer fix # rector, pint, then the docs composer mcp list # the server's tools composer mcp call api '{}' # call one
The Rules section above is generated from the rules themselves: their class
names, class doc comments and rule definitions. Everything between the
rules:start and rules:end markers is written by composer docs, and
composer docs-check fails when it no longer matches. Document a rule by
writing its doc comment and its getRuleDefinition(), then run composer fix.
composer check requires a coverage driver (Xdebug or pcov); without one Pest
cannot satisfy the --min=100 gate.
License
MIT. See LICENSE.