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.20
- symplify/rule-doc-generator-contracts: ^11.2
Requires (Dev)
- laravel/pint: ^1.29
- nikic/php-parser: ^5.4
- orchestra/testbench: ^9.0||^10.11||^11.0
- 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.9
- spaze/phpstan-disallowed-calls: ^4.10
- symplify/phpstan-extensions: ^12.0
- tomasvotruba/cognitive-complexity: ^1.0
- tomasvotruba/type-coverage: ^2.0
- dev-main
- 1.2.1
- 1.2.0
- 1.1.0
- 1.0.0
- 0.22.3
- 0.22.2
- 0.22.1
- 0.22.0
- 0.21.1
- 0.21.0
- 0.20.2
- 0.20.1
- 0.20.0
- 0.19.1
- 0.19.0
- 0.18.0
- 0.17.1
- 0.17.0
- 0.16.0
- 0.15.0
- 0.14.1
- 0.14.0
- 0.13.3
- 0.13.2
- 0.13.1
- 0.13.0
- 0.12.1
- 0.12.0
- 0.11.0
- 0.10.1
- 0.10.0
- 0.9.0
- 0.8.1
- 0.8.0
- 0.7.0
- 0.6.1
- 0.6.0
- 0.5.3
- 0.5.2
- 0.5.1
- 0.5.0
- 0.4.19
- 0.4.18
- 0.4.17
- 0.4.16
- 0.4.15
- 0.4.14
- 0.4.13
- 0.4.12
- 0.4.11
- 0.4.10
- 0.4.9
- 0.4.8
- 0.4.7
- 0.4.6
- 0.4.5
- 0.4.4
- 0.4.3
- 0.4.2
- 0.4.1
- 0.4.0
- 0.3.2
- 0.3.1
- 0.3.0
- 0.2.0
- 0.1.1
- 0.1.0
This package is auto-updated.
Last update: 2026-04-29 15:25:33 UTC
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
- Versioning policy — what's covered by SemVer
- Compatibility — supported runtime + integration versions
- 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
Operation
- Formatter integration — what the rector emits and how Pint / PHP-CS-Fixer finish the job
- Diagnostics — skip log + verbosity tiers
- Parity — runtime-equivalence harness for semantics-changing rectors
#[FluentRules]opt-in — per-method opt-in attribute
Reference
- Public API — frozen surface (symbols, wire keys, behavior)
- Known limitations
- License
Installation
composer require --dev sandermuller/laravel-fluent-validation-rector
Requirements: PHP 8.2+, Rector 2.4+, sandermuller/laravel-fluent-validation ^1.20.
If you're on an older fluent-validation:
| fluent-validation | Pin rector to |
|---|---|
| 1.17 – 1.19 | ^0.8 |
| 1.20+ | ^0.13 (latest) |
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.
Versioning policy
This package follows SemVer 2.0.
MAJOR (X.y.z) — breaking changes to the public API:
- Rename / remove a rector class
- Rename / remove a
FluentValidationSetListconstant - Rename / remove / change-string-value of a rector configuration constant
- Change the skip-log line prefix or the per-run header structure
- Rename / remove the verbose-mode env var or its accepted values
- Change either documented skip-log path
MINOR (x.Y.z) — additive, non-breaking:
- New rector class
- New configuration constant on an existing rector
- New skip-log diagnostic emit site
- New configuration option value (additive enum extension)
- Wider match conditions on an existing rector (transformations now apply to shapes that previously fell through)
PATCH (x.y.Z) — bug fixes:
- Correctness fixes to existing transformations
- Diagnostic message text changes (the content, not the line format)
- Internal refactors with no observable effect on output
- Documentation updates
The package's "public API" is the explicit list in PUBLIC_API.md. Symbols not on that list are @internal and may change in any release without a MAJOR bump.
PATCH-level rector changes must not introduce parity violations against existing fixtures (see Parity below). Behavioral drift in semantics-changing rectors is a MINOR or MAJOR bump depending on whether existing consumers can opt in/out.
Compatibility
Tested matrix (CI)
Every push runs the full cross-product below under both prefer-lowest and prefer-stable Composer resolutions:
| OS | PHP | Laravel |
|---|---|---|
| ubuntu-latest | 8.3, 8.4 | 11, 12, 13 |
| windows-latest | 8.3, 8.4 | 11, 12, 13 |
Each Laravel-major leg pins the matching orchestra/testbench major (9.x / 10.x / 11.x). Underlying floor: PHP 8.2+ per require.php; Laravel range tracks orchestra/testbench constraint in composer.json.
Runtime-detected integrations (no direct dependency)
| Integration | Detection mechanism | Versions handled |
|---|---|---|
| Livewire | extends Livewire\Component ancestry |
v3 + v4 |
| Filament | InteractsWithForms (v3/v4) / InteractsWithSchemas (v5) trait presence |
v3 + v4 + v5 |
| Nova | extends Laravel\Nova\Resource ancestry |
v4 + v5 |
These integrations are detected at rector-time, not depended upon. The rector handles them when the consumer's project includes them; absent the host packages, the corresponding code paths are dormant.
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)
ValidationStringToFluentRuleRector
Converts pipe-delimited rule strings ('required|string|max:255') to fluent chains.
- Where it fires: FormRequest
rules(),$request->validate(),Validator::make(), plusRuleSet::from([...])wrappers anywhere in PHP source (FormRequestrules(): RuleSetreturns, action methods, services, controllers, jobs — added 1.1.0). The wrapper itself stays intact; only the inner array converts.
ValidationArrayToFluentRuleRector
Converts array-based rules (['required', 'string', Rule::unique(...)]), including Rule:: objects, Password::min() chains, conditional tuples, closures, and custom rule objects.
-
Where it fires: same surface as
ValidationStringToFluentRuleRector— FormRequestrules(),$request->validate(),Validator::make(), plusRuleSet::from([...])wrappers anywhere in PHP source (added 1.1.0). -
Conditional tuples accept:
- Explicit enum-value args:
['exclude_unless', 'type', Enum::CASE->value] - In-tuple variadic spread on variadic fluent signatures:
['required_unless', $field, ...Enum::list()]→->requiredUnless($field, ...Enum::list())
- Explicit enum-value args:
-
Conditional tuples bail: spread targeting non-variadic methods (
excludeWith,requiredIfAccepted), or placed on the rule-name / field position. Array form preserved. -
Non-conditional tuples accept dynamic expressions:
['max', $this->limit ?? 10],['between', config('a'), config('b')],['max', match($x) { ... }], via a permissive emittable-arg check on the fluent-lowering and->rule([...])escape-hatch paths. -
Non-conditional tuples bail on: object/callable/array producers (
new Obj(),fn() => 5,[1, 2]) and side-effectful mutators ($x = 5,$i++). Preserves the original failure mode. -
COMMA_SEPARATED conditional rules keep strict string-like args to avoid
Closure|bool|string $fieldoverload ambiguity.
InlineResolvableParentRulesRector
Inlines parent::rules() when it appears as a spread at index 0 of a child rules(). Unblocks the converter rectors, which otherwise bail on spread items.
- Handles:
...parent::rules()when the parent is a plainreturn [...];...$basewhen$baseis the method's only top-level assignment and its RHS is a literal array orparent::rules(). Covers the$base = parent::rules(); return [...$base, 'new' => '...'];idiom.
- Bails on: parents that merge, concatenate, or call methods over their return; methods with peer top-level assignments, nested-scope assignments (
if/foreach/try), or multi-use variables. - Runs first in
CONVERTso the flattened shape reachesValidationString/ArrayToFluentRuleRector.
ConvertLivewireRuleAttributeRector
Strips Livewire #[Rule('...')] / #[Validate('...')] property attributes and generates a rules(): array method.
- Handles:
- String, list-array, and keyed-array shapes.
#[Validate(['todos' => 'required', 'todos.*' => '...'])]expands into onerules()entry per key. - Constructor-form rule objects (
new Password(8),new Unique('users'),new Exists('roles')) lower toFluentRule::password(8)/->unique(...)/->exists(...)the same as their static-factory counterparts. - Maps
as:/attribute:to->label()in both string and array forms. When both are present,attribute:wins on conflict. - Keeps an empty
#[Validate]marker on converted properties sowire:model.livereal-time validation survives conversion. Opt out viaPRESERVE_REALTIME_VALIDATION => false.
- String, list-array, and keyed-array shapes.
- Bails on: hybrid
$this->validate([...])calls (softenable, see config below), final parentrules()methods, unsupported attribute args, numeric keyed-array keys, and theHasFluentValidation-trait compose conflict (an ancestor uses the trait AND the child carries#[Rule]/#[Validate]— the trait'sgetRules()reads onlyrules(): arrayso the attribute is silently ignored at runtime, and rector-side conversion would override the parent'srules()and drop parent-owned fields). Each bail logged to the skip file (see Diagnostics). Direct trait use on the class itself is not a bail — the rector merges the attribute rule into a localrules()array (or installs one), since neither failure mode applies in that shape. - Config:
KEY_OVERLAP_BEHAVIOR => 'partial'softens the classwide bail on explicit$this->validate([...])to a per-property overlap check. Converts non-overlapping attrs, leaves overlapping ones plus the explicit call alone. See config keys.
Grouping (set GROUP)
GroupWildcardRulesToEachRector
Folds flat wildcard and dotted keys into nested each() / children() calls. Applies to FormRequests and Livewire components alike.
- Bails on (each emits a specific skip-log entry under
=actionable):- Wildcard group has non-FluentRule entries —
'items' => ['required', ...]next to'items.*' => FluentRule::.... - Parent rule's factory doesn't support
each()/children()— onlyFluentRule::array()andFluentRule::field()do. - Wildcard parent (
items.*) has type-specific rules that grouping would silently drop. - Double wildcard (
**) or non-first*in a key suffix. - Concat-keyed wildcard (
$prefix . '.*.foo') where the prefix isn't a static class constant.
- Wildcard group has non-FluentRule entries —
- Notes:
- On Livewire, the
HasFluentValidationtrait'sgetRules()override flattens the nested form back to wildcard keys at runtime, so grouping is safe. - When a dot-notation key has no explicit parent rule, synthesizes a bare
FluentRule::array()parent so nestedrequiredchildren still fire. - Wildcard-prefix concat keys (
'*.' . CONST_NAME => …) fold into'*' => array()->children([CONST_NAME => …, …])when every sibling in the group resolves the suffix from a self/static class constant. Mixed groups with literal'*.foo'siblings keep the literal-keyed entries unchanged and bail-and-log the const branch (no rule loss; partial conversion). rules()methods returningRuleSet::from([...])(the canonicalsandermuller/laravel-fluent-validationshape) are folded by descending into the array argument. TheRuleSet::from(...)wrapper stays intact; only the wrapped array is rewritten. Branched-return bodies (multiple top-level returns) bail-with-log uniformly to avoid partial cross-branch rewrites.
- On Livewire, the
Traits (set TRAITS)
AddHasFluentRulesTraitRector
Adds use HasFluentRules; to FormRequests that use FluentRule.
AddHasFluentValidationTraitRector
Adds the fluent-validation trait to Livewire components that use FluentRule.
- Variant picking:
- Plain Livewire component →
HasFluentValidation. - Filament's
InteractsWithForms(v3/v4) orInteractsWithSchemas(v5) used directly on the class →HasFluentValidationForFilament+ a 4-methodinsteadofblock. - Wrong variant already directly on a class → swaps to the right one and drops the orphaned import.
- Plain Livewire component →
- Bails on: ancestor-only Filament usage. PHP method resolution through inheritance is fragile, so the user must add the trait on the concrete subclass. Skip-logged.
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)
SIMPLIFY is opt-in, not bundled into ALL. Run it as a separate pass after you've verified the initial conversion.
PromoteFieldFactoryRector
Promotes FluentRule::field() to a typed factory (::string(), ::numeric(), etc.) when every ->rule(...) wrapper in the chain resolves to a v1-scope rule whose target method lives on exactly one typed FluentRule subclass.
- Why: unblocks
SimplifyRuleWrappersRector's next pass.FluentRule::field()->rule('max:61')becomesFluentRule::string()->max(61)instead of staying on the escape hatch. - Also promotes:
FluentRule::string()->rule(Password::default())/->rule(Email::default())→FluentRule::password()/::email()(same zero-arg source, single Password/Email match, no Conditionable hops). - Bails on:
- Conditionable hops in the chain.
- Chains whose compatible-class intersection isn't a singleton.
field()->rule('accepted')/field()->rule('declined'). The would-beboolean()factory's seed constraint rejects"yes"/"on"/"true"(accepted) and"no"/"off"/"false"(declined), inputs the original Laravel rule permits (including HTML checkbox defaults). The post-bail skip-log line names the blocked promotion target so consumers can decide between keeping the escape hatch or explicitly usingFluentRule::boolean()->accepted().
- Semantic note:
StringRuleadds Laravel's implicitstringrule (likewisenumericforNumericRule);FieldRuleadds neither. Promoting therefore changes validation behavior on non-string inputs. Intent matches in nearly allmax(N)cases, but review the diff. - Runs first in
SIMPLIFYso the promoted factory reaches the wrapper rewrites.
SimplifyFluentRuleRector
Cleans up FluentRule chains after migration.
- Handles: factory shortcuts (
string()->url()→url()),->label()folded into the factory arg,min()+max()→between(), redundant type removal. - Bails on:
min()+max()fold when either method carriesmessageFor('min'/'max')or a positionalmessage(). Would silently drop the message binding.- Factory-shortcut promotion when the chain has a
label()call OR the shortcut method isn't adjacent to the factory (preserves user intent and message-binding slots).
SimplifyRuleWrappersRector
Rewrites escape-hatch ->rule(...) calls into native typed-rule methods.
-
Handles:
Rule family Receivers Notes in/notInString/Numeric/Email/Field/DateHasEmbeddedRulesconsumersmin/max/betweenper-class allowlist EmailRulehas onlymaxregexStringRuleonlysize→exactlyString/Numeric/Array/FileLaravel's size:renamed in fluent-validation perTypedBuilderHintenumHasEmbeddedRulesconsumerstyped-rule allowlist Literal-zero comparison helpers NumericRulegt:0→->positive(),gte:0→->nonNegative(),lt:0→->negative(),lte:0→->nonPositive(). Non-zero literals + field refs stay escape.Zero-arg string tokens typed receivers with matching method 'accepted','declined','present','prohibited','nullable','sometimes','required','filled' -
Array-form COMMA_SEPARATED conditional rules:
->rule(['required_if', 'field', 'value'])→->requiredIf('field', 'value'). Covers field-plus-variadic-values rules (required_if/exclude_unless) and pure variadic-fields rules (required_with/prohibits). BackedEnum cases in tail positions auto-wrap with->value. Category Crequired_if_acceptedand Category Dexclude_withstay as escape hatch. -
Receiver-type inference: walks the chain back to the
FluentRule::*()factory. Steps throughConditionableproxy hops (->when(...)/->unless(...)/->whenInput(...)) when the closure body is a bare-return / no-return /fn ($r) => $ridentity. Proxy hops with other closure shapes bail. -
Bails on: variable receivers, methods absent from the resolved typed-rule class.
-
Runs after
SimplifyFluentRuleRectorso factory shortcuts apply first.
InlineMessageParamRector
Collapses ->message('...') / ->messageFor('key', '...') chain calls into the inline message: named parameter on FluentRule factories and rule methods. Requires sandermuller/laravel-fluent-validation ^1.20 (earlier floors get zero rewrites via the reflection-time surface probe).
-
Three rewrite predicates:
- Factory-direct:
FluentRule::email()->message('Bad')→FluentRule::email(message: 'Bad'). Requires->message()immediately on the factory with no intervening rule method or Conditionable hop. - Rule-method matched-key:
->min(3)->messageFor('min', 'Too short.')→->min(3, message: 'Too short.'). - Rule-object:
->rule(new In([...]))->messageFor('in', 'Pick one.')→->rule(new In([...]), message: 'Pick one.').
- Factory-direct:
-
Skip categories (each emits a user-facing log entry):
Category Examples Why Variadic-trailing requiredWith/containsinline binds to wrong slot Composite digitsBetween/DateRule::between/ImageRule::dimensionsinline binds to last sub-rule Mode-modifier EmailRule::strict/PasswordRule::lettersdon't call addRuleDeferred-key factories date/dateTimeL11/L12-divergent PasswordgetFromLocalArrayshortRule lookup is L12+ onlytemplate lists password.letters/password.mixedsub-key alternatives for L11 consumersNo-implicit-constraint factories field/anyOf -
Pre-existing user misbindings (
->min(3)->messageFor('max', ...)) stay chained silently. Not rector's job to fix.
Docblock polish (set POLISH)
POLISH is opt-in, not bundled into ALL. Run it as a separate pass after CONVERT stabilizes (multi-pass convergence requires the final shape).
UpdateRulesReturnTypeDocblockRector
Narrows the @return PHPDoc annotation on rules() methods from the wide array<string, ValidationRule|string|array<mixed>> union down to array<string, \SanderMuller\FluentValidation\Contracts\FluentRuleContract> when every value in the returned array is a FluentRule::*() call chain. Cosmetic (runtime behavior untouched), but gives PHPStan and editors a narrower type to reason about.
- Qualifying classes:
FormRequestsubclasses (anywhere in the ancestor chain, aliased imports included) and classes usingHasFluentRules/HasFluentValidation/HasFluentValidationForFilamentdirectly or via ancestors. - Narrowed only: methods with no existing
@return,@return array, or the wide-union annotation this package's converters emit. - Respected (left untouched): user-customized annotations,
@inheritDoc, widened unions/intersections, any non-prose suffix. - Skipped when:
- The returned array isn't a single literal
Array_(multi-return, builder variants,RuleSet::from(...), collection pipelines). - Any value isn't a FluentRule chain (
Rule::in(...),new Custom(), closures, string rules, ternary / match). - The method has
): ?arrayor unkeyed items.
- The returned array isn't a single literal
- Run as a separate pass after
CONVERTstabilizes. Rector's multi-pass convergence means it eventually fires on the final shape, but a single-invocation rector run that mixesCONVERT+POLISHmay require a second invocation if any file had string-rule items mid-convert.
Opting in: #[FluentRules] attribute
#[FluentRules] is a per-method opt-in attribute (defined in sandermuller/laravel-fluent-validation) that signals "convert this method's rule array, even though my class doesn't fall under one of the auto-qualifying shapes (FormRequest / fluent-validation trait / Livewire)." It also lifts the abstract-class safety guard when applied to rules() itself, treating the attribute as the user's audit assertion that subclasses don't manipulate parent::rules() as a plain array.
Use #[FluentRules] when:
- You have a method on a non-FormRequest / non-Livewire / non-trait class that holds rules under a name other than
rules()— e.g. a custom Validator subclass'srulesWithoutPrefix(). The attribute qualifies the class for processing and tells the converter to walk that specific method's body. - You have an abstract class with
rules()whose subclasses you have audited to confirm none manipulateparent::rules()with array merges. The attribute is your assertion of audit-safety; the package's safety guard for abstract classes is bypassed for the attributedrules()method.
Do NOT use #[FluentRules] on:
- Methods named after Eloquent / Laravel framework hooks (
casts(),messages(),attributes(),toArray(),jsonSerialize(), etc.) — the denylist guard catches misapplied attributes, drops them silently for class-qualification AND conversion purposes, and emits a skip-log warning so you notice the mistake. - Abstract methods whose subclasses you have NOT audited. Converting the parent silently breaks subclasses that do
array_merge(parent::rules(), [...]). The attribute is per-method: applying it to a sibling helper does NOT lift the abstract-with-rules()guard for the unattributedrules()itself.
Per-method scoping. The audit assertion is per-method, not class-wide. #[FluentRules] on rulesWithoutPrefix() qualifies the class for processing and converts that specific method, but does not enable class-wide auto-detection of unrelated rule-shaped helpers — those would still need their own #[FluentRules] attribute to convert. This narrowing prevents "stray rule token in an unrelated helper gets rewritten as validation rules" regressions.
What #[FluentRules] does NOT do. The attribute is a narrow per-method opt-in for rule conversion. It does NOT lift the package's other safety guards:
- Cross-class parent-safety. If any subclass manipulates
parent::rules()with array functions (array_merge,array_search, bracket assignment,collect()->merge*()), the parent stays unsafe and refuses conversion — even when the parent'srules()method carries#[FluentRules]. The attribute is the user's claim about their own method's audit-safety, not a license to override the cross-class scan. Audit your subclass usage and refactor the merge points if you need the parent to convert. - Shape-changing transformations on Validator subclasses. When a class qualifies solely via
#[FluentRules]and is a subclass of a Validator (extends FluentValidator extends Validator), the converter rectors run butGroupWildcardRulesToEachRectorskips with a documented log message. The fold rewrites'*.foo' + '*.bar'into'*' => array()->children([...]), which is structurally equivalent under FormRequest dispatch but breaks Validator subclasses whose parent class postprocessesrulesWithoutPrefix()output (e.g.JsonImportValidator::rulesWithPrefix()walks the array and prepends a per-key prefix — the nested-children shape doesn't round-trip). Wrap the wildcard rules manually if you have audited the parent's behavior; the rector won't silently fold in this case. - The denylisted-method guard.
#[FluentRules]oncasts(),messages(),attributes(),toArray(),jsonSerialize(), etc. is silently dropped for class-qualification AND conversion purposes, and a skip-log warning fires so you notice the mistake. The denylist always wins regardless of whether the attribute is present.
Sets
| Set | Rules |
|---|---|
ALL |
CONVERT + GROUP + TRAITS (the full migration pipeline) |
CONVERT |
InlineResolvableParentRulesRector, ValidationStringToFluentRuleRector, ValidationArrayToFluentRuleRector, ConvertLivewireRuleAttributeRector |
GROUP |
GroupWildcardRulesToEachRector |
TRAITS |
AddHasFluentRulesTraitRector, AddHasFluentValidationTraitRector |
SIMPLIFY |
PromoteFieldFactoryRector, SimplifyFluentRuleRector, SimplifyRuleWrappersRector, InlineMessageParamRector — post-migration cleanup, run as a separate pass after verifying the initial conversion |
POLISH |
UpdateRulesReturnTypeDocblockRector — narrow @return docblocks to FluentRuleContract |
// 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]) // Docblock polish (run separately after CONVERT stabilizes) ->withSets([FluentValidationSetList::POLISH])
Note
Don't bundle ALL + SIMPLIFY + POLISH into a single config call. SIMPLIFY runs after manual diff review of the initial conversion; POLISH needs CONVERT's multi-pass output to stabilize. Each is a separate vendor/bin/rector process invocation against its own withSets([...]) block.
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, ]);
The full rule list (any of these can be registered individually without pulling the whole set):
| Rule | Set (opt-in) | Purpose |
|---|---|---|
InlineResolvableParentRulesRector |
CONVERT (included in ALL) |
inline ...parent::rules() spread when parent is plain return [...] |
ValidationStringToFluentRuleRector |
CONVERT (included in ALL) |
pipe-delimited rule strings → FluentRule chains |
ValidationArrayToFluentRuleRector |
CONVERT (included in ALL) |
array-based rules + Rule::/Password:: objects → FluentRule chains |
ConvertLivewireRuleAttributeRector |
CONVERT (included in ALL) |
Livewire #[Rule] / #[Validate] → generated rules() method |
GroupWildcardRulesToEachRector |
GROUP (included in ALL) |
flat wildcard/dotted keys → nested each() / children() |
AddHasFluentRulesTraitRector |
TRAITS (included in ALL) |
adds use HasFluentRules; to FormRequests that use FluentRule |
AddHasFluentValidationTraitRector |
TRAITS (included in ALL) |
adds Livewire trait (plain or Filament variant) to Livewire components |
PromoteFieldFactoryRector |
SIMPLIFY (not in ALL) |
FluentRule::field()->rule('max:61') → FluentRule::string() when wrappers narrow to one typed subclass |
SimplifyFluentRuleRector |
SIMPLIFY (not in ALL) |
factory shortcuts, ->between(), redundant-type cleanup |
SimplifyRuleWrappersRector |
SIMPLIFY (not in ALL) |
->rule('in:a,b') / ->rule(Rule::in([...])) / ->rule('size:N') → native typed-rule methods (->in([...]), ->exactly(N), etc.) |
InlineMessageParamRector |
SIMPLIFY (not in ALL) |
->message('x') / ->messageFor('key', 'x') on factories + rule methods → inline message: named param (requires fluent-validation ^1.20) |
UpdateRulesReturnTypeDocblockRector |
POLISH (not in ALL) |
narrow @return on pure-fluent rules() to FluentRuleContract |
Configurable rules
Four rules accept configuration via withConfiguredRule().
ConvertLivewireRuleAttributeRector config
| Key | Type | Default | What it does |
|---|---|---|---|
PRESERVE_REALTIME_VALIDATION |
bool |
true |
When true, converted #[Validate] properties retain an empty #[Validate] marker so wire:model.live real-time validation survives conversion. Opt out with false on codebases that don't use wire:model.live and find the marker noisy in converted diffs. |
MIGRATE_MESSAGES |
bool |
false |
When true, message: attribute args migrate into a generated messages(): array method alongside rules(). String message: 'X' → '<prop>' => 'X'; array message: ['rule' => 'X'] → '<prop>.<rule>' => 'X' (full-path keys passthrough verbatim for keyed-array first-arg attributes). Opt-in: expands class surface; some consumers centralize messages in lang files. Bails on unmergeable existing messages(). |
KEY_OVERLAP_BEHAVIOR |
'bail' | 'partial' |
'bail' |
Controls what happens when a class has #[Validate] attrs AND an explicit $this->validate([...]) call. 'bail' preserves 0.12 semantics — classwide skip. 'partial' converts attrs whose predicted emit keys don't appear in any explicit validate([...]) array; overlapping attrs + the explicit call stay intact. Only direct Array_ / RuleSet::compileToArrays(<literal>) accepted; anything else forces classwide bail. |
SimplifyRuleWrappersRector config
| Key | Type | Default | What it does |
|---|---|---|---|
TREAT_AS_FLUENT_COMPATIBLE |
list<string> |
[] |
Consumer-declared allowlist of rule-factory FQCNs whose output is FluentRule-compatible. Patterns support * (single namespace segment) and ** (recursive). Silences "rule payload not statically resolvable" skip log on shapes rector can't introspect — e.g. ->rule(App\Rules\Domain\DutchPostcodeRule::create()). |
ALLOW_CHAIN_TAIL_ON_ALLOWLISTED |
bool |
false |
When a chain ends in ->someMethod() after an allowlisted factory call, default preserves the tail. Flip on if your allowlist covers factories whose tails always return another FluentRule-compatible node. |
UpdateRulesReturnTypeDocblockRector config
Same two keys as SimplifyRuleWrappersRector (TREAT_AS_FLUENT_COMPATIBLE, ALLOW_CHAIN_TAIL_ON_ALLOWLISTED). Allowlisted items count as FluentRule for the narrow-@return-tag decision. Mixed arrays (allowlisted items + string/array entries) with an existing narrow FluentRuleContract tag emit a stale-narrow skip-log warning.
Per-rector configuration. Each rector receives its own configuration array via
withConfiguredRule(...); the values are not pooled across rectors. When the same wire key appears on bothSimplifyRuleWrappersRectorandUpdateRulesReturnTypeDocblockRector, pass the key on each rector that consumes it — configuring only one will leave the other running with a default-empty allowlist (silent partial config; docblocks won't narrow on your custom factories). The DTO builder section below shows the recommended shared-instance pattern.
AddHasFluentRulesTraitRector config
| Key | Type | Default | What it does |
|---|---|---|---|
BASE_CLASSES |
list<string> |
[] |
Opt-in list of FormRequest base class names that should also receive the trait. Default is auto-detection on concrete FormRequests that use FluentRule — this list adds named shared bases on top of that path. Leave empty to use auto-detection only. |
use SanderMuller\FluentValidationRector\Rector\ConvertLivewireRuleAttributeRector; return RectorConfig::configure() ->withConfiguredRule(ConvertLivewireRuleAttributeRector::class, [ ConvertLivewireRuleAttributeRector::PRESERVE_REALTIME_VALIDATION => false, ]);
Typed configuration (DTO builders)
Each configurable rector has an opt-in DTO builder under
SanderMuller\FluentValidationRector\Config\ that produces the same wire-key array
via a ->toArray() terminal step. The builders give you compile-time type safety,
IDE autocomplete, and immutable composition without changing anything on the rector
side — the rector's configure(array) signature is unchanged and the array shape is
identical. The constant-array form keeps working alongside the DTO form; pick whichever
fits your rector.php style.
| Rector | DTO | Shared types |
|---|---|---|
ConvertLivewireRuleAttributeRector |
Config\LivewireConvertOptions |
Config\Shared\OverlapBehavior (enum) |
SimplifyRuleWrappersRector |
Config\RuleWrapperSimplifyOptions |
Config\Shared\AllowlistedFactories |
UpdateRulesReturnTypeDocblockRector |
Config\DocblockNarrowOptions |
Config\Shared\AllowlistedFactories |
AddHasFluentRulesTraitRector |
Config\HasFluentRulesTraitOptions |
Config\Shared\BaseClassRegistry |
use Rector\Config\RectorConfig; use SanderMuller\FluentValidationRector\Config\DocblockNarrowOptions; use SanderMuller\FluentValidationRector\Config\HasFluentRulesTraitOptions; use SanderMuller\FluentValidationRector\Config\LivewireConvertOptions; use SanderMuller\FluentValidationRector\Config\RuleWrapperSimplifyOptions; use SanderMuller\FluentValidationRector\Config\Shared\AllowlistedFactories; use SanderMuller\FluentValidationRector\Config\Shared\BaseClassRegistry; use SanderMuller\FluentValidationRector\Config\Shared\OverlapBehavior; use SanderMuller\FluentValidationRector\Rector\AddHasFluentRulesTraitRector; use SanderMuller\FluentValidationRector\Rector\ConvertLivewireRuleAttributeRector; use SanderMuller\FluentValidationRector\Rector\SimplifyRuleWrappersRector; use SanderMuller\FluentValidationRector\Rector\UpdateRulesReturnTypeDocblockRector; // `AllowlistedFactories` is shared across BOTH rectors that consume it. // Build it once and feed it to each rector's options DTO so the two stay // in lockstep on what counts as "fluent-compatible" — configuring only // one would leave the other running with default-empty allowlist // (silent partial config; docblocks won't narrow on your custom factories). $allowlist = AllowlistedFactories::none() ->withFactories(['App\\Rules\\CustomRule']) ->allowingChainTail(); return RectorConfig::configure() ->withConfiguredRule( ConvertLivewireRuleAttributeRector::class, LivewireConvertOptions::default() ->withMessageMigration() ->withOverlapBehavior(OverlapBehavior::Partial) ->toArray(), ) ->withConfiguredRule( SimplifyRuleWrappersRector::class, RuleWrapperSimplifyOptions::with($allowlist)->toArray(), ) ->withConfiguredRule( UpdateRulesReturnTypeDocblockRector::class, DocblockNarrowOptions::with($allowlist)->toArray(), ) ->withConfiguredRule( AddHasFluentRulesTraitRector::class, HasFluentRulesTraitOptions::default() ->withBaseClasses(BaseClassRegistry::of(['App\\Http\\Requests\\BaseRequest'])) ->toArray(), );
Cross-rector shared DTOs are the canonical multi-rector form. The example above shows the lockstep pattern: a single $allowlist instance feeds both SimplifyRuleWrappersRector::with(...) and UpdateRulesReturnTypeDocblockRector's DocblockNarrowOptions::with(...). Adding a class to the allowlist updates both surfaces atomically. Configuring only one of the two rectors leaves the other running with an empty allowlist — the kind of silent-partial-config that produces no error but quietly skips your custom factories on the un-configured rector's surface.
The ::with(...) named constructor is shorthand for ::default()->withAllowlistedFactories(...) — both produce identical output. Use whichever reads better at the call site; mixed-style is fine.
Same pattern for the trait-add rector's base-class allowlist:
return RectorConfig::configure() ->withConfiguredRule( AddHasFluentRulesTraitRector::class, HasFluentRulesTraitOptions::with( BaseClassRegistry::of(['App\\Http\\Requests\\BaseRequest']), )->toArray(), );
Formatter integration
The rector emit is not formatter-clean by design. Run a formatter (Pint, PHP-CS-Fixer, or equivalent) after vendor/bin/rector process to normalize output. The recommended pipeline:
vendor/bin/rector process && vendor/bin/pint --dirty
Three cosmetic seams 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
The skip log is opt-in as of 0.5.0. In default runs, bail-capable rules still count skips and the end-of-run summary reports the total, but no file is written to your project root:
[fluent-validation] 42 skip entries. Re-run with FLUENT_VALIDATION_RECTOR_VERBOSE=actionable and --clear-cache for details.
FLUENT_VALIDATION_RECTOR_VERBOSE accepts three values (case-insensitive), introduced in 0.13:
| Value | Surfaces |
|---|---|
| unset / empty | off — only always-actionable entries get counted; no file is written. |
actionable |
recommended — adds verbose entries labeled actionable (payloads that need manual migration, stale @return docblocks, etc.); suppresses structural noise like "trait already present" / "class is Livewire, routed to the other rector". |
1 / true / all |
everything — legacy behavior, includes the structural noise. =1 kept as alias so existing CI scripts keep working. |
# Recommended entry point — signal only, no noise FLUENT_VALIDATION_RECTOR_VERBOSE=actionable vendor/bin/rector process --clear-cache # Full firehose (legacy, still supported) FLUENT_VALIDATION_RECTOR_VERBOSE=1 vendor/bin/rector process --clear-cache
Env-only is deliberate. The flag has to reach parallel workers (fresh PHP processes spawned via proc_open), and shell-exported env inherits automatically; an in-process putenv() wrapper would not. Exporting the variable one step above the rector invocation keeps a single source of truth that every worker sees.
Any opt-in tier writes .cache/rector-fluent-validation-skips.log (plus a .session sentinel used to coordinate truncation across parallel workers) and the end-of-run line points at it:
[fluent-validation] 42 skip entries written to .cache/rector-fluent-validation-skips.log — see for details
At the legacy =1 / =all tier, the same line appends a tip pointing at the actionable filter — the =all firehose typically dominates with structural noise (trait-already-present, parent-inherits-trait, Livewire-detected) which =actionable filters out. Production dogfood on a 5-Livewire-component Laravel 12 / Filament v5 app measured 110 entries at =all vs. 5 at =actionable on the same surface:
[fluent-validation] 110 skip entries written to .cache/rector-fluent-validation-skips.log — see for details (tip: FLUENT_VALIDATION_RECTOR_VERBOSE=actionable filters informational entries)
The .cache/ subdir matches Rector's own cache directory convention — most projects already gitignore it. The first line of the log is a per-run header recording the package version, ISO-8601 UTC timestamp, and verbose tier, useful for cross-release diff stability in CI:
# laravel-fluent-validation-rector 0.14.1 — generated 2026-04-26T11:47:12Z
# verbose tier: actionable
[fluent-validation:skip] ...
The header is always emitted when verbose mode is on, even on zero-entry runs, so the file's existence stays stable across runs. Pre-0.14.1 the log lived at <cwd>/.rector-fluent-validation-skips.log; the package automatically cleans up that legacy path on first run after the upgrade.
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.
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: (use MIGRATE_MESSAGES to opt in), explicit onUpdate:, or 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.
Parity
A small subset of rectors changes which Laravel rule object handles validation at runtime. The functional test suite proves source→source AST shape; the parity harness under tests/Parity/ proves the resulting rule sets produce equivalent error bags when Laravel runs them. Together they cover both structural and behavioral correctness.
In-scope rectors (semantics may change):
SimplifyRuleWrappersRector— promotesfield()->rule('accepted')to typed factory chains.GroupWildcardRulesToEachRector— folds wildcard sibling keys intoeach(...).PromoteFieldFactoryRector— rewritesfield()->required()->rule('string')tostring()->required().
Pure-refactor rectors (Validation*ToFluentRule, AddHasFluent*Trait, ConvertLivewireRuleAttribute, Inline*, UpdateRulesReturnTypeDocblock, SimplifyFluentRule) ship with structural coverage only — their transformations don't change which rule class handles validation.
Authoring a fixture. Each fixture lives at tests/Parity/Fixture/<RectorName>/<case>.php and returns:
return [ 'rules_before' => ['field' => 'pre-rector-rule-shape'], 'rules_after' => ['field' => FluentRule::typed()->...], 'payloads' => [ 'descriptive name' => ['field' => 'value-to-test'], ], // optional, only when the divergence is intentional: 'allowed_divergences' => [ 'descriptive name' => [ 'category' => DivergenceCategory::ImplicitTypeConstraint, 'rationale' => 'free-text explanation that lives next to the divergence', ], ], ];
The harness runs Validator::make($payload, $rules_before) and Validator::make($payload, $rules_after), then diffs the resulting error bags. Outcomes: MATCH, BEFORE_REJECTS_AFTER_PASSES, AFTER_REJECTS_BEFORE_PASSES, BOTH_REJECT_DIFFERENT_MESSAGES, BOTH_REJECT_DIFFERENT_ORDER, or SKIPPED (DB / closure denylist).
Allowed divergences. Some transformations legitimately change behavior — e.g. boolean()->accepted() rejects 'yes' / 'on' strings that bare accepted accepts because of boolean()'s implicit type pre-check. Categorize via DivergenceCategory enum:
ImplicitTypeConstraint— typed rule attaches an implicit constraint absent from the pre-rector form.MessageKeyDrift— same fail outcome, different underlying message-key path.AttributeLabelDrift— same fail,:attributesubstitution renders differently.OrderDependentPipeline— same messages, different per-field order.
The category constrains the allowed runtime outcome; mismatched category fails the test. The free-text rationale lives next to the divergence so future readers see why it's acceptable.
Coverage gate. tests/Parity/CoverageTest.php asserts every in-scope rector has ≥1 fixture. New semantics-changing rectors must extend the in-scope list AND ship at least one fixture before merging.
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 inside
withValidator()callbacks.withValidator()is a post-validation hook for adding custom errors via$validator->after(...), not a rules definition. No FluentRule equivalent — imperative code stays. - Rules built via
Collection::put()->merge()->all()chains. Runtime-resolved collection pipelines aren't statically determinable. Out of scope unless a narrow shape (pure literalput()chain ending in->all()) gathers consumer demand. - Multi-statement helper bodies. Auto-detection requires a single-statement
return [...];shape. Helpers likeprivate function buildRules() { $rules = [...]; return $rules; }stay untouched. Inline the return or convert by hand.
Already covered (not limitations): Validator::validate(...), the global validator(...) helper (when prefixed with \ or in the global namespace), and custom-named rules methods (editorRules(), rulesWithoutPrefix(), etc.) on classes that qualify as rules-bearing (FormRequest descendants / fluent-validation-trait users / Livewire components / #[FluentRules]-marked methods). The converters auto-detect rules-shaped methods by content signature — a string-keyed return [...]; whose values include a recognized rule string, Rule::*() call, FluentRule chain, or constructor-form rule object — without any consumer config.
- Ternary picking the rule NAME.
['nullable', $flag ? 'email' : 'url'](where the ternary chooses a different rule) 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. Not a limitation: ternaries / method calls / function calls / match / nullsafe property fetches as a rule's argument —['max', $cond ? 15 : 20],['between', config('a'), config('b')],['max', $this->limit ?? 10]— convert fine via the permissive emittable-arg path on non-conditional tuples (seeValidationArrayToFluentRuleRector). #[Validate(..., onUpdate: true)]/translate: false. These attribute args have no FluentRule builder equivalent and no migration path. They land in the skip log so you can move them to Livewire's hooks or project config manually. The rule string,as:/attribute:label, andonUpdate: false(consumed as a real-time-validation opt-out marker) are migrated.message:is opt-in: enableMIGRATE_MESSAGESto migrate string and arraymessage:args into a generatedmessages(): arraymethod alongsiderules(). WithMIGRATE_MESSAGESoff (default),message:args also land in the skip log for manual migration.
License
MIT