sandermuller / laravel-fluent-validation-rector
Rector rules for migrating Laravel validation to sandermuller/laravel-fluent-validation
Package info
github.com/SanderMuller/laravel-fluent-validation-rector
Type:rector-extension
pkg:composer/sandermuller/laravel-fluent-validation-rector
Requires
- php: ^8.2
- rector/rector: ^2.4.1
- sandermuller/laravel-fluent-validation: ^1.8.1
- symplify/rule-doc-generator-contracts: ^11.2
Requires (Dev)
- laravel/pint: ^1.29
- nikic/php-parser: ^5.4
- orchestra/testbench: ^9.0||^10.11
- pestphp/pest: ^3.0||^4.4
- phpstan/extension-installer: ^1.4
- phpstan/phpstan: ^2.0
- phpstan/phpstan-strict-rules: ^2.0
- rector/type-perfect: ^2.0
- sandermuller/package-boost: ^0.2
- spaze/phpstan-disallowed-calls: ^4.10
- symplify/phpstan-extensions: ^12.0
- tomasvotruba/cognitive-complexity: ^1.0
- tomasvotruba/type-coverage: ^2.0
README
Rector rules for migrating Laravel validation to sandermuller/laravel-fluent-validation. Pipe-delimited strings, array-based rules, Rule:: objects, and Livewire #[Rule] attributes all convert to FluentRule method chains.
// Before public function rules(): array { return [ 'email' => 'required|email|max:255', 'tags' => ['nullable', 'array'], 'tags.*' => 'string|max:50', ]; } // After public function rules(): array { return [ 'email' => FluentRule::email()->required()->max(255), 'tags' => FluentRule::array()->nullable()->each( FluentRule::string()->max(50), ), ]; }
Tested on a production codebase: 448 files converted, 3469 tests still passing.
Contents
Getting started
- Installation
- Quick start
- Rules shipped — what gets converted and what stays
Usage
- Sets — mix and match subsets of the migration pipeline
- Individual rules — when you need one specific conversion
Output
- Formatter integration — what the rector emits and how Pint / PHP-CS-Fixer finish the job
- Diagnostics — skip log, cache interactions, manual spot-checks
Reference
Installation
composer require --dev sandermuller/laravel-fluent-validation-rector
Requires PHP 8.2+, Rector 2.4+, and sandermuller/laravel-fluent-validation ^1.8.1. The 1.8.1 constraint matters if you have Filament+Livewire components: the HasFluentValidationForFilament trait overrides four methods that also exist on Filament's InteractsWithForms / InteractsWithSchemas, so an insteadof adaptation is required. Rector emits the adaptation for you on direct Filament compositions.
Quick start
// rector.php use Rector\Config\RectorConfig; use SanderMuller\FluentValidationRector\Set\FluentValidationSetList; return RectorConfig::configure() ->withPaths([__DIR__ . '/app']) ->withSets([FluentValidationSetList::ALL]);
vendor/bin/rector process --dry-run # preview vendor/bin/rector process # apply vendor/bin/pint # format
The ALL set runs the full migration pipeline (converters + grouping + trait insertion) on every file under app/. For most codebases that's enough; the output is ready to commit after Pint runs. If you want finer control, pick subsets via Sets or register individual rules.
Rules shipped
Grouped by the set that includes them. FluentValidationSetList::ALL runs everything in Converters + Grouping + Traits; SIMPLIFY is a separate post-migration cleanup set you opt into after verifying the initial conversion.
Converters (set CONVERT)
ValidationStringToFluentRuleRectorconverts pipe-delimited rule strings ('required|string|max:255') to fluent chains. Works in FormRequestrules(),$request->validate(), andValidator::make().ValidationArrayToFluentRuleRectorconverts array-based rules (['required', 'string', Rule::unique(...)]), includingRule::objects,Password::min()chains, conditional tuples, closures, and custom rule objects.ConvertLivewireRuleAttributeRectorstrips Livewire#[Rule('...')]/#[Validate('...')]property attributes and generates arules(): arraymethod. Handles string, list-array, and keyed-array shapes;#[Validate(['todos' => 'required', 'todos.*' => '...'])]expands into onerules()entry per key. Mapsas:/attribute:to->label()in both string and array forms (when both are present,attribute:wins on conflict). For#[Validate]properties, it keeps an empty#[Validate]marker on the property sowire:model.livereal-time validation survives conversion. Opt out via thepreserve_realtime_validation => falseconfig. Bails on edge cases (hybrid$this->validate([...])calls, final parentrules()methods, unsupported attribute args, numeric keyed-array keys) and logs each one to the skip file — see Diagnostics.
Grouping (set GROUP)
GroupWildcardRulesToEachRectorfolds flat wildcard and dotted keys into nestedeach()/children()calls. Applies to FormRequests and Livewire components alike. On Livewire, theHasFluentValidationtrait'sgetRules()override flattens the nested form back to wildcard keys at runtime, so the grouping is safe. When a dot-notation key has no explicit parent rule, the rector synthesizes a bareFluentRule::array()parent so nestedrequiredchildren still fire.
Traits (set TRAITS)
AddHasFluentRulesTraitRectoraddsuse HasFluentRules;to FormRequests that use FluentRule.AddHasFluentValidationTraitRectoradds the fluent-validation trait to Livewire components that use FluentRule. PicksHasFluentValidationfor plain Livewire components, orHasFluentValidationForFilament+ a 4-methodinsteadofblock when Filament'sInteractsWithForms(v3/v4) orInteractsWithSchemas(v5) is used directly on the class. Ancestor-only Filament usage is skip-logged (PHP method resolution through inheritance is fragile; user must add the trait on the concrete subclass). If the wrong variant is already directly on a class, the rector swaps it to the right one and drops the orphaned import.
Tip
If your codebase has a shared FormRequest or Livewire base, declare use HasFluentRules; (or HasFluentValidation) on the base once and every subclass inherits it. The trait rectors walk the ancestor chain via ReflectionClass and won't re-add the trait on subclasses, so no base_classes configuration is needed.
Post-migration (set SIMPLIFY)
SimplifyFluentRuleRectorcleans up FluentRule chains after migration: factory shortcuts (string()->url()→url()),->label()folded into the factory arg,min()+max()→between(), redundant type removal. Run it as a separate pass after you've verified the initial conversion. It's not included inALLby default.
Sets
| Set | Includes |
|---|---|
ALL |
Convert + Group + Traits (full migration pipeline) |
CONVERT |
String, array, and #[Rule] attribute converters |
GROUP |
Wildcard/dotted-key grouping into each() |
TRAITS |
Performance trait insertion for FormRequest and Livewire |
SIMPLIFY |
Post-migration chain cleanup |
// Just conversion, no grouping or traits ->withSets([FluentValidationSetList::CONVERT]) // Conversion + traits, skip grouping ->withSets([ FluentValidationSetList::CONVERT, FluentValidationSetList::TRAITS, ]) // Post-migration cleanup (run separately after verifying) ->withSets([FluentValidationSetList::SIMPLIFY])
Individual rules
When you need a single conversion (a one-off migration of a specific codebase path, or running just the array-based converter on a subset of files), import and register the rule class directly:
use SanderMuller\FluentValidationRector\Rector\ValidationStringToFluentRuleRector; use SanderMuller\FluentValidationRector\Rector\ValidationArrayToFluentRuleRector; return RectorConfig::configure() ->withRules([ ValidationStringToFluentRuleRector::class, ValidationArrayToFluentRuleRector::class, ]);
Configurable rules
Two rules accept configuration via withConfiguredRule():
ConvertLivewireRuleAttributeRector—PRESERVE_REALTIME_VALIDATION(bool, defaulttrue). When true, converted#[Validate]properties retain an empty#[Validate]marker sowire:model.livereal-time validation survives conversion. Opt out withfalseon codebases that don't usewire:model.liveand find the marker noisy in converted diffs.AddHasFluentRulesTraitRector—BASE_CLASSES(list of strings). Opt-in list of FormRequest base class names that should receive the trait. Leave empty to skip the trait-insertion path entirely; set to a class list to target shared bases.
use SanderMuller\FluentValidationRector\Rector\ConvertLivewireRuleAttributeRector; return RectorConfig::configure() ->withConfiguredRule(ConvertLivewireRuleAttributeRector::class, [ ConvertLivewireRuleAttributeRector::PRESERVE_REALTIME_VALIDATION => false, ]);
Formatter integration
The rector's output is valid PHP but has three cosmetic seams that a formatter resolves automatically. The fixer names below are from PHP-CS-Fixer; Pint ships the same set under the same names as part of its default Laravel preset.
- Imports are inserted at prepend position (not alphabetical). The
ordered_importsfixer resolves. - Unused imports may be left in place (e.g. a
Livewire\Attributes\Ruleimport after the attribute is stripped). Theno_unused_importsfixer resolves. - Generated
@returndocblocks emitIlluminate\Contracts\Validation\ValidationRuleas a fully-qualified reference. Thefully_qualified_strict_typesfixer hoists it to ausestatement + short-name reference.
All three are in Pint's default Laravel preset, so most Laravel consumers have them without explicit configuration. PHP-CS-Fixer users on a custom ruleset should verify the three fixers are enabled. Without any formatter you'll see rougher-than-example output, but the code is still valid PHP.
Tip
For the cleanest pre-formatter output, enable ->withImportNames()->withRemovingUnusedImports() in your rector.php:
return RectorConfig::configure() ->withImportNames() ->withRemovingUnusedImports() ->withSets([FluentValidationSetList::ALL]);
Note
The rector doesn't insert line breaks between method calls. FluentRule::string()->required()->max(255) is valid PHP on a single line and keeps diffs minimal. If you prefer multi-line chains, the method_chaining_indentation fixer (Pint / PHP-CS-Fixer) reflows them after Rector runs.
Diagnostics
If a file you expected to convert wasn't touched, check .rector-fluent-validation-skips.log in your project root. Every bail-capable rule writes a one-line reason there: unsupported attribute args, a hybrid $this->validate([...]) call, a trait already present on an ancestor class, and so on.
The log is a file sink because Rector's withParallel(...) executor doesn't forward worker STDERR to the parent. A diagnostic line written via fwrite(STDERR, ...) from a worker would vanish on parallel runs (Rector's default). A file sink survives worker death and you can inspect it from the project root after the run finishes. If you're writing your own Rector rule and want similar diagnostics, the same gotcha applies: withParallel() + STDERR means silent data loss.
At the end of each Rector invocation, a single STDOUT line surfaces the log's existence:
[fluent-validation] 42 skip entries written to .rector-fluent-validation-skips.log — see for details
Tip
Rector caches per-file results. Files that hit a bail produce no transformation, so the skip entry is written once and the rule is not re-invoked on cached runs. To force every file to be revisited and every bail to be re-logged, run vendor/bin/rector process --clear-cache (or delete .cache/rector*).
Note
ConvertLivewireRuleAttributeRector verifies the generated rules(): array is syntactically correct, but it can't prove the converted rule is behaviorally equivalent to the source attribute. If a converted Livewire component has no feature test covering validation, review the diff by hand and watch for dropped message: / explicit onUpdate: / translate: false args (logged to the skip file) that need manual migration to Livewire's messages(): array hook or project config. messages: (plural, not a Livewire-documented arg) surfaces its own "unrecognized, likely typo for message:?" log entry.
Known limitations
- Namespace-less files. Classes at the file root without a
namespaceare silently skipped by the grouping and trait rectors. Laravel projects always use namespaces, so this rarely comes up in practice. - Rules built outside
rules(): array. The rector looks forrules(): array,$request->validate([...]), andValidator::make([...]). Rules built insidewithValidator()callbacks, customrulesWithoutPrefix()conventions, or Action-classCollection::put()->merge()chains are left alone. - Ternary rule strings.
['nullable', $flag ? 'email' : 'url']is left alone. A->when(cond, thenFn, elseFn)conversion is tractable in principle but wasn't worth it: three separate codebase audits turned up near-zero usage (single digits across a 100+ FormRequest corpus), and the closure-based fluent form loses the terseness users reach for ternaries to preserve. UseRule::when(...)or branch the rules array outside the ternary instead. #[Validate(..., message: '...')]/ explicitonUpdate: true/translate: false. These attribute args have no FluentRule builder equivalent. The rule string,as:/attribute:label, andonUpdate: false(consumed as a real-time-validation opt-out marker) are migrated; the remaining args are written to the skip log so you can migrate them to Livewire'smessages(): arrayhook or project config manually. Array-formmessage: [...]is deferred to a future release.
License
MIT