Simplify
SIMPLIFY is opt-in and not part of ALL. Run it as its own pass once you have reviewed the initial conversion. Its four rectors run in the order below.
PromoteFieldFactoryRector
Promotes FluentRule::field() to a typed factory when every ->rule(...) wrapper in the chain resolves to a rule whose target method lives on exactly one typed subclass. FluentRule::field()->rule('max:61') becomes FluentRule::string()->max(61), which unblocks the escape-hatch cleanup below.
Promoting changes validation behaviour. StringRule adds Laravel's implicit string rule, NumericRule adds numeric; FieldRule adds neither. Intent matches in nearly every max(N) case, but the diff is worth reading.
Other promotions and bail conditions
string()->rule(Password::default())/->rule(Email::default())→FluentRule::password()/::email().field()->required()->rule('accepted')→FluentRule::accepted()->required(), and thedeclinedanalog: those factories seed the constraint in their constructors, so the->rule()hop is spliced out. Promotion toboolean()stays blocked, because boolean's implicit constraint rejects"yes"/"on"/"true"and"no"/"off"/"false".- Bails on conditionable hops, chains whose compatible-class intersection is not a singleton, and
accepted/declinedchains carrying further->rule(...)payloads.
SimplifyFluentRuleRector
Factory shortcuts (string()->url() → url()), ->label() folded into factory args, min() + max() → between(), redundant type removal.
Bail conditions
- The
min()+max()fold bails when either carriesmessageFor('min'/'max')or a positionalmessage(), because folding would drop the binding. - Factory-shortcut promotion bails when the chain has a
label()call, or the shortcut method is not adjacent to the factory.
SimplifyRuleWrappersRector
Rewrites escape-hatch ->rule(...) calls into native typed methods. Runs after SimplifyFluentRuleRector so shortcuts apply first. Takes an allowlist for factories it cannot introspect.
Rewrite table
| Rule family | Receivers | Notes |
|---|---|---|
in / notIn | String/Numeric/Email/Field/Date | HasEmbeddedRules consumers |
min / max / between | per-class allowlist | EmailRule has only max |
regex | StringRule only | |
size → exactly | String/Numeric/Array/File | renamed per TypedBuilderHint |
enum | HasEmbeddedRules consumers | typed-rule allowlist |
| Literal-zero comparisons | NumericRule | gt:0 → ->positive(), gte:0 → ->nonNegative(), and so on. Non-zero literals and field refs stay escape |
| Zero-arg string tokens | receivers with a matching method | accepted, declined, present, prohibited, nullable, sometimes, required, filled |
Conditional rules and receiver inference
- COMMA_SEPARATED conditional rules in array, string and concat form all lower to native methods:
->rule(['required_if', 'field', 'value']),->rule('required_if:field,value')and->rule('required_with:' . self::FIELD). String form splits the tail on commas; the fluent variadic re-joins with,, so escaped or quoted commas round-trip. Concat form is scoped to the pure-field family and gated to statically simple string-oriented operands, so a method call, arithmetic or ternary operand stays an escape hatch. BackedEnum cases in tail positions auto-wrap with->value.required_if_acceptedandexclude_withstay escape hatches. Rule::facade conditionals (Rule::requiredIf($cond)and siblings) pass the singleClosure|boolthrough verbatim. Bails on multi-arg calls, on a literalnullcondition (valid Laravel, but->requiredIf(null)wouldTypeError), and on named args, since param names differ between facade and builder.- Receiver inference walks back to the
FluentRule::*()factory, stepping throughConditionableproxy hops when the closure is a bare return, no return, orfn ($r) => $r. Other closure shapes bail, as do variable receivers and methods absent from the resolved class.
InlineMessageParamRector
Collapses ->message('…') and ->messageFor('key', '…') into the inline message: named parameter. Needs fluent-validation ^1.20; earlier floors get zero rewrites via a reflection-time probe.
Rewrite predicates and skip categories
Three predicates: factory-direct (FluentRule::email()->message('Bad') → FluentRule::email(message: 'Bad'), only with no intervening hop), rule-method matched-key (->min(3)->messageFor('min', 'Too short.') → ->min(3, message: 'Too short.')), and rule-object (->rule(new In([...]))->messageFor('in', '…')).
Skipped, each with a log entry: variadic-trailing methods (requiredWith, contains) where inline would bind to the wrong slot; composite methods (digitsBetween, DateRule::between, ImageRule::dimensions) where it would bind to the last sub-rule; mode modifiers (EmailRule::strict, PasswordRule::letters) that never call addRule; deferred-key factories (date, dateTime); L11/L12-divergent Password; and factories with no implicit constraint (field, anyOf).
Pre-existing user misbindings (->min(3)->messageFor('max', …)) stay chained silently. Not the rector's to fix.