# Laravel Fluent Validation Rector > Rector rules for migrating Laravel validation to sandermuller/laravel-fluent-validation: pipe strings, rule arrays, Rule:: objects and Livewire attributes all convert to FluentRule chains. Full documentation, 19 pages, in reading order. Index: https://sandermuller.github.io/laravel-fluent-validation-rector/llms.txt # Why this package? Rector rules that migrate Laravel validation to [`sandermuller/laravel-fluent-validation`](https://github.com/sandermuller/laravel-fluent-validation). Pipe-delimited strings, array-based rules, `Rule::` objects and Livewire `#[Rule]` attributes all become FluentRule chains. ```php // Before return [ 'email' => 'required|email|max:255', 'tags' => ['nullable', 'array'], 'tags.*' => 'string|max:50', ]; // After return [ 'email' => FluentRule::email()->required()->max(255), 'tags' => FluentRule::array()->nullable()->each( FluentRule::string()->max(50), ), ]; ``` Tested on a production codebase: **448 files converted, 3,469 tests still passing.** ## What it will not do The rectors bail rather than guess. A shape they cannot prove equivalent is left exactly as it was and written to the [skip log](https://sandermuller.github.io/laravel-fluent-validation-rector/diagnostics), so a migration is never silently lossy. [Detection and limitations](https://sandermuller.github.io/laravel-fluent-validation-rector/limitations) lists what stays untouched and why. ## What it costs you One `rector.php`, a formatter pass after it, and a diff review. Two of the sets, [`SIMPLIFY`](https://sandermuller.github.io/laravel-fluent-validation-rector/simplify) and [`POLISH`](https://sandermuller.github.io/laravel-fluent-validation-rector/polish), are deliberately not in `ALL`: they run after you have verified the first conversion. # Installation ```bash composer require --dev sandermuller/laravel-fluent-validation-rector ``` **Requirements:** PHP 8.3+, Rector 2.5+, and [`sandermuller/laravel-fluent-validation`](https://github.com/sandermuller/laravel-fluent-validation) ^1.32.0. On an older fluent-validation, pin the rector to match: | fluent-validation | Pin rector to | |---|---| | 1.17 – 1.19 | `^0.8` | | 1.20 – 1.26 | `>=1.0 <1.4` | | 1.27 – 1.31 | `>=1.4 <1.9` | | 1.32+ | `^1.9` (latest) | # Getting started ```php // rector.php use Rector\Config\RectorConfig; use SanderMuller\FluentValidationRector\Set\FluentValidationSetList; return RectorConfig::configure() ->withPaths([__DIR__ . '/app']) ->withSets([FluentValidationSetList::ALL]); ``` ```bash vendor/bin/rector process --dry-run # preview vendor/bin/rector process # apply vendor/bin/pint # format ``` `ALL` runs converters, grouping and trait insertion over everything under `app/`. For most codebases that is the whole migration, and the output is ready to commit once Pint has run, because the emit is [deliberately not formatter-clean](https://sandermuller.github.io/laravel-fluent-validation-rector/formatter). For finer control, pick [subsets](https://sandermuller.github.io/laravel-fluent-validation-rector/sets) or register [individual rules](https://sandermuller.github.io/laravel-fluent-validation-rector/rules-reference). Two sets stay out of `ALL` on purpose. Run each as its own invocation, after the previous one has settled: ```bash vendor/bin/rector process # ALL, then review the diff # …then, separately: vendor/bin/rector process # SIMPLIFY vendor/bin/rector process # POLISH ``` # Sets | Set | Rules | |---|---| | `ALL` | `CONVERT` + `GROUP` + `TRAITS`: the full migration pipeline | | `CONVERT` | [`InlineResolvableParentRulesRector`](https://sandermuller.github.io/laravel-fluent-validation-rector/converters), [`ValidationStringToFluentRuleRector`](https://sandermuller.github.io/laravel-fluent-validation-rector/converters), [`ValidationArrayToFluentRuleRector`](https://sandermuller.github.io/laravel-fluent-validation-rector/converters), [`ConvertLivewireRuleAttributeRector`](https://sandermuller.github.io/laravel-fluent-validation-rector/livewire) | | `GROUP` | [`GroupWildcardRulesToEachRector`](https://sandermuller.github.io/laravel-fluent-validation-rector/grouping) | | `TRAITS` | [`AddHasFluentRulesTraitRector`](https://sandermuller.github.io/laravel-fluent-validation-rector/traits), [`AddHasFluentValidationTraitRector`](https://sandermuller.github.io/laravel-fluent-validation-rector/traits) | | `SIMPLIFY` | [Four post-migration cleanups](https://sandermuller.github.io/laravel-fluent-validation-rector/simplify), **not** in `ALL` | | `POLISH` | [`UpdateRulesReturnTypeDocblockRector`](https://sandermuller.github.io/laravel-fluent-validation-rector/polish), **not** in `ALL` | | `SCHEMA` | [`ConvertToFluentSchemaRector`](https://sandermuller.github.io/laravel-fluent-validation-rector/schema), **not** in `ALL` | ```php // Just conversion ->withSets([FluentValidationSetList::CONVERT]) // Conversion + traits, no grouping ->withSets([ FluentValidationSetList::CONVERT, FluentValidationSetList::TRAITS, ]) ``` **Do not bundle `ALL` + `SIMPLIFY` + `POLISH` + `SCHEMA` into one config.** `SIMPLIFY` is meant to run after you have reviewed the initial diff, and `POLISH` needs `CONVERT`'s multi-pass output to have stabilized. Each is its own `vendor/bin/rector process` invocation against its own `withSets([...])`. # String and array converters Three rectors in `CONVERT` rewrite rule arrays. All of them fire in FormRequest `rules()`, `$request->validate()`, `Validator::make()`, and `RuleSet::from([...])` wrappers anywhere in PHP source. The wrapper stays; only the inner array converts. ## `ValidationStringToFluentRuleRector` Pipe-delimited strings (`'required|string|max:255'`) become fluent chains. ## `ValidationArrayToFluentRuleRector` Array-based rules (`['required', 'string', Rule::unique(...)]`), including `Rule::` objects, `Password::min()` chains, conditional tuples, closures and custom rule objects.
Conditional tuples and dynamic arguments - **Conditional tuples accept**: explicit enum-value args (`['exclude_unless', 'type', Enum::CASE->value]`), and in-tuple variadic spread on variadic fluent signatures (`['required_unless', $field, ...Enum::list()]`). - **Conditional tuples bail** on spread targeting non-variadic methods (`excludeWith`, `requiredIfAccepted`), or spread on the rule-name or field position. The array form is preserved. - **Non-conditional tuples accept dynamic expressions**: `['max', $this->limit ?? 10]`, `['between', config('a'), config('b')]`, `['max', match($x) { ... }]`. - **Non-conditional tuples bail** on object/callable/array producers (`new Obj()`, `fn() => 5`, `[1, 2]`) and side-effectful mutators (`$x = 5`, `$i++`). - **COMMA_SEPARATED conditional rules** keep strict string-like args, to avoid `Closure|bool|string $field` overload ambiguity. - **Dynamic concat rule strings** (`'required_if_accepted:' . $field`) lower to the native method when the rule name is a static leading literal and the rule takes the tail as one string. Otherwise they stay on a string-coercion-safe `->rule()`. - **`Rule::` presence conditionals** (`requiredIf`, `requiredUnless`, `excludeIf`, `excludeUnless`, `prohibitedIf`, `prohibitedUnless`) convert when the argument is a closure or bool literal; matching is case-insensitive. Any other shape could be read as a *field name*, so the native array stays. - **Composite `Rule::` builders bail**: `Rule::when()` / `Rule::unless()` (`ConditionalRules`) and `Rule::forEach()` (`NestedRules`) have no faithful fluent equivalent.
## `InlineResolvableParentRulesRector` Inlines `parent::rules()` when it is a spread at index 0 of a child `rules()`, unblocking the converters, which otherwise bail on spread items. Runs first in `CONVERT`.
Supported shapes and bail conditions - **Handles** `...parent::rules()` when the parent is a plain `return [...];`, and `...$base` when `$base` is the method's only top-level assignment and its right-hand side is a literal array or `parent::rules()`. That covers the `$base = parent::rules(); return [...$base, 'new' => '...'];` idiom. - **Bails on** parents that merge, concatenate or call methods over their return, and on methods with peer top-level assignments, assignments in nested scope (`if` / `foreach` / `try`), or multi-use variables.
# Livewire attributes ## `ConvertLivewireRuleAttributeRector` Strips Livewire `#[Rule('...')]` and `#[Validate('...')]` property attributes and generates a `rules(): array` method. Three [config keys](https://sandermuller.github.io/laravel-fluent-validation-rector/configuration#convertlivewireruleattributerector) change what it does with messages, overlaps and real-time validation.
Supported shapes and bail conditions - **Handles**: - String, list-array and keyed-array shapes. `#[Validate(['todos' => 'required', 'todos.*' => '...'])]` expands to one `rules()` entry per key. - Constructor-form rule objects (`new Password(8)`, `new Unique('users')`, `new Exists('roles')`) lower the same as their static-factory counterparts. - Maps `as:` / `attribute:` to `->label()`. When both appear, `attribute:` wins. - Keeps an empty `#[Validate]` marker on converted properties so `wire:model.live` real-time validation survives. Opt out with `PRESERVE_REALTIME_VALIDATION => false`. - **Bails on**: hybrid `$this->validate([...])` calls (softenable via `KEY_OVERLAP_BEHAVIOR`), final parent `rules()` methods, unsupported attribute args, numeric keyed-array keys, and the `HasFluentValidation` compose conflict, where an ancestor uses the trait *and* the child carries `#[Rule]` / `#[Validate]`. There the trait's `getRules()` reads only `rules(): array`, so the attribute is already ignored at runtime, and converting would override the parent's `rules()` and drop parent-owned fields. Every bail is written to the [skip log](https://sandermuller.github.io/laravel-fluent-validation-rector/diagnostics). Direct trait use on the class itself is **not** a bail. The rector merges the attribute rule into a local `rules()` array, since neither failure mode applies there.
**A converted component needs a diff review.** The rector verifies the generated `rules(): array` is syntactically correct; it cannot prove it behaves identically to the source attribute. If the component has no feature test covering validation, read the diff and watch for dropped `message:` (opt in with [`MIGRATE_MESSAGES`](https://sandermuller.github.io/laravel-fluent-validation-rector/configuration#convertlivewireruleattributerector)), explicit `onUpdate:`, or `translate: false`. All three are logged, and all need manual migration to Livewire's `messages(): array` hook or project config. A `messages:` arg (plural, not a Livewire argument) gets its own "likely typo for `message:`?" entry. # Wildcard grouping ## `GroupWildcardRulesToEachRector` Folds flat wildcard and dotted keys into nested `each()` / `children()` calls, in FormRequests and Livewire components alike. When the folded children are [`FluentSchema`](https://sandermuller.github.io/laravel-fluent-validation-rector/schema) chains, the synthesized parent is emitted in instance form (`$rules->array()->children([...])`) to match the receiver. ```php // Before 'tags' => FluentRule::array()->nullable(), 'tags.*' => FluentRule::string()->max(50), // After 'tags' => FluentRule::array()->nullable()->each( FluentRule::string()->max(50), ), ``` On Livewire this is safe: the `HasFluentValidation` trait's `getRules()` flattens the nested form back to wildcard keys at runtime.
Bail conditions Each emits its own [skip-log](https://sandermuller.github.io/laravel-fluent-validation-rector/diagnostics) entry under `=actionable`: - A wildcard group with non-FluentRule entries, such as `'items' => ['required', ...]` beside `'items.*' => FluentRule::...`. - A parent factory without `each()` / `children()`. Only `FluentRule::array()` and `FluentRule::field()` have them. - A wildcard parent (`items.*`) carrying type-specific rules that folding would silently drop. - A double wildcard (`**`), or a non-first `*` in a key suffix. - A concat-keyed wildcard (`$prefix . '.*.foo'`) whose prefix is not a static class constant. - Branched-return bodies (several top-level returns) bail uniformly, rather than rewrite across branches.
Edge cases it handles - A dot-notation key with no explicit parent gets a synthesized bare `FluentRule::array()` parent, so nested `required` children still fire. - A `FluentRule::field()->…->rule(Rule::array())` parent is promoted to `array()` before folding, because the array factory seeds the same implicit rule, but only `array()` exposes `each()`. Gated to provably equivalent chains: no label arg on the `field()` root, the only rule hop is the bare array rule, and every other hop exists on both `FieldRule` and `ArrayRule`. Labeled parents, `FieldRule`-only methods, conditionable closures, object-valued `->rule(...)` hops, a `message()` bound to the array rule, keyed `Rule::array([...])` and macro-based size constraints keep the escape hatch. - Wildcard-prefix concat keys (`'*.' . CONST_NAME => …`) fold when every sibling resolves its suffix from a self/static class constant. A mixed group keeps its literal-keyed entries and bails-with-log on the const branch. Partial conversion, no rule loss. - `rules()` returning `RuleSet::from([...])` folds by descending into the array argument; the wrapper stays intact.
# Trait insertion The `TRAITS` set adds the fluent-validation trait to classes that now use `FluentRule`. ## `AddHasFluentRulesTraitRector` Adds `use HasFluentRules;` to FormRequests using FluentRule, or declaring a [`schema(FluentSchema $rules)`](https://sandermuller.github.io/laravel-fluent-validation-rector/schema) builder, which needs the trait to dispatch. Configurable with a [`BASE_CLASSES`](https://sandermuller.github.io/laravel-fluent-validation-rector/configuration#addhasfluentrulestraitrector) allowlist. **Abstract bases are skipped by default**, since a subclass may array-manipulate `parent::rules()` and a base-level trait would then be wrong. Marking the base's `rules()` with [`#[FluentRules]`](https://sandermuller.github.io/laravel-fluent-validation-rector/fluent-rules-attribute) asserts subclass-safety and adds the trait anyway, so the base flows through the full `ALL` + `SCHEMA` pipeline. Listing the base in `base_classes` works too. ## `AddHasFluentValidationTraitRector` Adds the trait to Livewire components using FluentRule, picking the plain or Filament variant from direct trait usage.
Variant selection and bail conditions - Plain Livewire component → `HasFluentValidation`. - Filament's `InteractsWithForms` (v3/v4) or `InteractsWithSchemas` (v5) used **directly** on the class → `HasFluentValidationForFilament` plus a four-method `insteadof` block. - The wrong variant already on a class → swapped, and the orphaned import dropped. - **Bails on ancestor-only Filament usage.** PHP method resolution through inheritance is fragile here, so the trait has to go on the concrete subclass by hand. Skip-logged.
**If you have a shared base**, declare `use HasFluentRules;` (or `HasFluentValidation`) on it once, and every subclass inherits it. Both rectors walk the ancestor chain by reflection and will not re-add a trait a parent already has, so no configuration is needed for that case. # 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 the `declined` analog: those factories seed the constraint in their constructors, so the `->rule()` hop is spliced out. Promotion to `boolean()` 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` / `declined` chains 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 carries `messageFor('min'/'max')` or a positional `message()`, 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](https://sandermuller.github.io/laravel-fluent-validation-rector/configuration#simplifyrulewrappersrector) 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_accepted` and `exclude_with` stay escape hatches. - **`Rule::` facade conditionals** (`Rule::requiredIf($cond)` and siblings) pass the single `Closure|bool` through verbatim. Bails on multi-arg calls, on a literal `null` condition (valid Laravel, but `->requiredIf(null)` would `TypeError`), and on named args, since param names differ between facade and builder. - **Receiver inference** walks back to the `FluentRule::*()` factory, stepping through `Conditionable` proxy hops when the closure is a bare return, no return, or `fn ($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.
# Docblock polish `POLISH` is **opt-in** and not part of `ALL`. Run it after `CONVERT` has stabilized, since the rector needs the final shape. ## `UpdateRulesReturnTypeDocblockRector` Narrows the `@return` on `rules()` from the wide `array>` union to `array` when every value in the returned array is a FluentRule chain. Runtime behaviour is untouched; PHPStan and editors get a narrower type.
What qualifies, what is left alone - **Qualifying classes**: `FormRequest` subclasses anywhere in the ancestor chain, aliased imports included, and classes using `HasFluentRules` / `HasFluentValidation` / `HasFluentValidationForFilament` directly or through an ancestor. - **Narrowed**: methods with no `@return`, with `@return array`, or with the wide union this package's converters emit. - **Left untouched**: user-customized annotations, `@inheritDoc`, widened unions and intersections, and any non-prose suffix. - **Skipped** when the returned array is not a single literal `Array_` (multi-return, builder variants, `RuleSet::from(...)`, collection pipelines), when any value is not a FluentRule chain (`Rule::in(...)`, `new Custom()`, closures, string rules, ternary, match), or when the method is `): ?array` or has unkeyed items.
Rector's multi-pass convergence means it eventually fires on the final shape, but a single run mixing `CONVERT` and `POLISH` may need a second invocation if any file had string-rule items mid-convert. It takes the same [allowlist keys](https://sandermuller.github.io/laravel-fluent-validation-rector/configuration#updaterulesreturntypedocblockrector) as `SimplifyRuleWrappersRector`. # Adopting FluentSchema `SCHEMA` is **opt-in** and not part of `ALL`. Adopting the instance-based builder is a style choice. Run it as its own pass once `CONVERT` and `TRAITS` have produced FluentRule chains on a `HasFluentRules` class. It needs fluent-validation ^1.32, whose `schema()`/`rules()` merge is what lets an `#[FluentRules]`-marked abstract base convert safely. ## `ConvertToFluentSchemaRector` Rewrites a `rules()` built from `FluentRule::` static chains into the `schema(FluentSchema $rules)` builder. The injected receiver drops the repeated prefix. ```php // Before public function rules(): array { return [ 'name' => FluentRule::string()->required()->max(255), 'email' => FluentRule::email()->required(), ]; } // After public function schema(FluentSchema $rules): array { return [ 'name' => $rules->string()->required()->max(255), 'email' => $rules->email()->required(), ]; } ``` **It only fires on `HasFluentRules` users.** The trait's `createDefaultValidator()` is the only runtime that dispatches a `schema(FluentSchema)` method, detected by the typed first parameter the container resolves. A plain FormRequest without the trait, a Livewire component (`HasFluentValidation` has no `schema()` hook), and a Filament page would all silently lose validation if `rules()` were renamed, so they are left alone. The gate resolves the trait directly, through `FluentFormRequest`, or through any ancestor. **It no-ops on an older install.** The builder and its dispatch shipped in fluent-validation 1.31. A reflection-time probe for the `FluentSchema` class makes the rule emit zero rewrites without it, because there `createDefaultValidator` still calls `rules()` and a converted `schema()` would never run. The composer floor is `^1.32`; the probe guards a path or dev install that bypasses it.
What it rewrites - **Every `FluentRule::x()` becomes `$rules->x()`.** `FluentSchema` mirrors each factory one-to-one and forwards macros through `__call`, so the swap preserves the produced rule. Nested chains inside `each([...])` and `children([...])` convert too. - **Self-referential `rules()` calls.** `parent::rules()` becomes `parent::schema($rules)` when the parent provably converts (a concrete or `#[FluentRules]`-opted `HasFluentRules` class with a public `rules()`, resolved by reflection) or already declares the builder. Re-running over a partly converted chain therefore finishes the child instead of stranding it. A base that stays on `rules()`, abstract and not opted in or from vendor, leaves the child unconverted so the call keeps resolving. `$this->rules()`, `self::rules()` and `static::rules()` are rewritten the same way; sibling calls like `parent::messages()` are never touched. - **Chains inside closures.** A chain or `parent::rules()` call inside a plain `function () { … }` converts: the receiver is swapped and the closure gains a `use ($builder)` capture, renamed if it would clash with the closure's own parameters. Arrow functions auto-capture. - **Imports.** Adds `use SanderMuller\FluentValidation\FluentSchema;` and drops the orphaned `FluentRule` import when nothing in the file still references the static factory. A type hint, a `FluentRule::class`, or an unconverted chain keeps it. - **Parameter naming.** The builder is `$rules` by convention. When the body already uses that local, as in the `$rules = […]; … return $rules;` assembly pattern, a free fallback name is chosen so the method converts instead of skipping.
Bail conditions - **An abstract class without `#[FluentRules]`.** The rename could break a subclass calling `parent::rules()` or dropping a base key. Add the attribute to the `rules()` method to assert subclass-safety; the ^1.32 merge then makes a subclass's `rules()` override merge with the renamed base rather than shadow it. Skip-logged as actionable. - A class that already declares `schema()`, since renaming would fatal on the duplicate. - A `rules()` with a non-standard signature: parameters, non-public, or static. - A `rules()` calling `parent::rules()` whose parent will not provably convert: abstract without the attribute, no trait, or an unresolvable vendor base. - A self-referential `rules()` call in a method other than `rules()`, which the rename would strand with no builder in scope. - A chain built in a scope the builder cannot be threaded into: an anonymous class or a nested named function. When such a chain sits beside a `parent::rules()` call the bail is skip-logged rather than silent, since leaving it would strand once the base converts.
## Ordering does not matter Every other rector resolves a chain's factory from both spellings: `FluentRule::string()` and `$rules->string()` on a `FluentSchema`-typed receiver, whether that is the `schema()` parameter or a `RuleSet::define(fn (FluentSchema $rules) => …)` closure. `AddHasFluentRulesTraitRector` also adds the trait to a hand-written `schema()` FormRequest. So `SCHEMA` can run before or after `SIMPLIFY`, `POLISH` and `GROUP`, and hand-written builder code is treated like static code. Rewrites keep the receiver: a `$rules->field()` promotion stays `$rules->string()`, and a wildcard fold synthesizes `$rules->array()->children([...])`. ## Process the inheritance chain together `ConvertToFluentSchemaRector` rewrites a child's `parent::rules()` when the parent *would* convert if processed, and Rector gives a rule no way to confirm the parent's file is in this run. Running `SCHEMA` over a lone child while leaving out its convertible parent rewrites the child and leaves the parent on `rules()`, producing `Call to undefined method parent::schema()`. PHPStan or the first request catches it immediately, so it is never silent, but avoid it: run the directory or the whole codebase, which the separate-pass workflow already prescribes. # The `#[FluentRules]` attribute A per-method opt-in, defined in [`sandermuller/laravel-fluent-validation`](https://github.com/sandermuller/laravel-fluent-validation). It says: convert this method's rule array even though the class is not a FormRequest, a trait user, or a Livewire component. Use it when: - A non-qualifying class holds rules under a name other than `rules()`, such as a custom Validator subclass's `rulesWithoutPrefix()`. The attribute qualifies the class and points the converter at that method. - An abstract class has `rules()` and you have **audited** its subclasses to confirm none merges `parent::rules()` as a plain array. The attribute is that audit assertion, and it lifts the abstract-class guard for the attributed method. Do **not** use it on: - Methods named after framework hooks: `casts()`, `messages()`, `attributes()`, `toArray()`, `jsonSerialize()`. The denylist drops the attribute for both qualification and conversion, and logs a warning so the mistake surfaces. - Abstract methods whose subclasses you have not audited. Converting the parent silently breaks a subclass doing `array_merge(parent::rules(), [...])`. The attribute is per-method: putting it on a sibling helper does not lift the guard for `rules()`. ## What it does not lift
Three guards the attribute has no effect on - **Cross-class parent safety.** If any subclass manipulates `parent::rules()` with array functions (`array_merge`, `array_search`, bracket assignment, `collect()->merge*()`, or the `+` union operator), the parent refuses conversion even with `#[FluentRules]` on it. The attribute is a claim about *your own* method, not a licence to override the cross-class scan. Refactor the merge points first. - **Shape-changing transforms on Validator subclasses.** When a class qualifies only via the attribute and extends a Validator, the converters run but `GroupWildcardRulesToEachRector` skips with a logged message. Folding `'*.foo'` + `'*.bar'` into `'*' => array()->children([...])` is equivalent under FormRequest dispatch, but breaks a Validator parent that postprocesses the rules array. One that walks it and prepends a per-key prefix, for instance, cannot round-trip the nested shape. Fold by hand if you have audited the parent. - **The denylist**, above. It always wins. **Scoping is per method.** The attribute on `rulesWithoutPrefix()` converts that method and qualifies the class; it does not turn on class-wide detection of other rule-shaped helpers. Each needs its own attribute. That narrowing is what stops a stray rule token in an unrelated helper from being rewritten as validation rules.
# Configuration Four rectors take configuration. Each receives its own array via `withConfiguredRule()`, and **values are not pooled between rectors.** When the same wire key appears on two of them, pass it to both; configuring one leaves the other on its default, which is silent partial config. ```php use SanderMuller\FluentValidationRector\Rector\ConvertLivewireRuleAttributeRector; return RectorConfig::configure() ->withConfiguredRule(ConvertLivewireRuleAttributeRector::class, [ ConvertLivewireRuleAttributeRector::PRESERVE_REALTIME_VALIDATION => false, ]); ``` ## `ConvertLivewireRuleAttributeRector` | Key | Type | Default | Effect | |---|---|---|---| | `PRESERVE_REALTIME_VALIDATION` | `bool` | `true` | Keeps an empty `#[Validate]` marker on converted properties so `wire:model.live` validation survives. Turn off on codebases without `wire:model.live` that find the marker noisy | | `MIGRATE_MESSAGES` | `bool` | `false` | Migrates `message:` args into a generated `messages(): array`. String → `'' => 'X'`; array → `'.' => 'X'`. Off by default because it expands the class surface and some projects centralize messages in lang files. Bails on an unmergeable existing `messages()` | | `KEY_OVERLAP_BEHAVIOR` | `'bail'` \| `'partial'` | `'bail'` | What to do when a class has both `#[Validate]` attrs and an explicit `$this->validate([...])`. `'bail'` skips the class; `'partial'` converts only attrs whose keys do not appear in the explicit array. Only a direct `Array_` or `RuleSet::compileToArrays()` is read; anything else bails classwide | ## `SimplifyRuleWrappersRector` | Key | Type | Default | Effect | |---|---|---|---| | `TREAT_AS_FLUENT_COMPATIBLE` | `list` | `[]` | FQCNs whose factory output is FluentRule-compatible. `*` matches one namespace segment, `**` recurses. Silences the "payload not statically resolvable" skip on shapes the rector cannot introspect | | `ALLOW_CHAIN_TAIL_ON_ALLOWLISTED` | `bool` | `false` | By default a `->someMethod()` tail after an allowlisted factory is preserved. Turn on when your allowlisted factories always return another compatible node | ## `UpdateRulesReturnTypeDocblockRector` The same two keys. Allowlisted items count as FluentRule for the narrowing decision. A mixed array with an existing narrow tag emits a stale-narrow warning. ## `AddHasFluentRulesTraitRector` | Key | Type | Default | Effect | |---|---|---|---| | `BASE_CLASSES` | `list` | `[]` | FormRequest **base** classes that should also get the trait. Auto-detection on concrete FormRequests runs regardless; this adds named shared bases on top | ## Typed builders Each configurable rector has an opt-in DTO under `SanderMuller\FluentValidationRector\Config\` producing the same wire-key array through `->toArray()`. Same output, with compile-time types and autocomplete. The constant-array form keeps working; pick per call site. | Rector | DTO | Shared type | |---|---|---| | `ConvertLivewireRuleAttributeRector` | `LivewireConvertOptions` | `Shared\OverlapBehavior` (enum) | | `SimplifyRuleWrappersRector` | `RuleWrapperSimplifyOptions` | `Shared\AllowlistedFactories` | | `UpdateRulesReturnTypeDocblockRector` | `DocblockNarrowOptions` | `Shared\AllowlistedFactories` | | `AddHasFluentRulesTraitRector` | `HasFluentRulesTraitOptions` | `Shared\BaseClassRegistry` | **Building a shared value once is the point.** `AllowlistedFactories` feeds both rectors that read it, so adding a class updates both surfaces at once: ```php $allowlist = AllowlistedFactories::none() ->withFactories(['App\\Rules\\CustomRule']) ->allowingChainTail(); return RectorConfig::configure() ->withConfiguredRule( SimplifyRuleWrappersRector::class, RuleWrapperSimplifyOptions::with($allowlist)->toArray(), ) ->withConfiguredRule( UpdateRulesReturnTypeDocblockRector::class, DocblockNarrowOptions::with($allowlist)->toArray(), ) ->withConfiguredRule( ConvertLivewireRuleAttributeRector::class, LivewireConvertOptions::default() ->withMessageMigration() ->withOverlapBehavior(OverlapBehavior::Partial) ->toArray(), ) ->withConfiguredRule( AddHasFluentRulesTraitRector::class, HasFluentRulesTraitOptions::with( BaseClassRegistry::of(['App\\Http\\Requests\\BaseRequest']), )->toArray(), ); ``` `::with(...)` is shorthand for `::default()->with…(...)`; both produce identical output. # Formatter integration **The emit is not formatter-clean by design.** Run a formatter after the rector: ```bash vendor/bin/rector process && vendor/bin/pint --dirty ``` Three cosmetic seams a formatter closes. The names are PHP-CS-Fixer's; Pint ships the same fixers under the same names in its default Laravel preset, so most Laravel projects already have them. 1. Imports are inserted at prepend position, not alphabetically. Use `ordered_imports`. 2. Unused imports may remain, such as a `Livewire\Attributes\Rule` import after the attribute is stripped. Use `no_unused_imports`. 3. Generated `@return` docblocks emit `Illuminate\Contracts\Validation\ValidationRule` fully qualified. `fully_qualified_strict_types` hoists it to a `use`. PHP-CS-Fixer users on a custom ruleset should check all three are enabled. Without any formatter the output is rougher than the examples here, but it is valid PHP. For the cleanest pre-formatter output: ```php return RectorConfig::configure() ->withImportNames() ->withRemovingUnusedImports() ->withSets([FluentValidationSetList::ALL]); ``` ## Line breaks Each generated call goes on its own line: ```php FluentRule::string() ->required() ->max(255); ``` The breaks are stamped only on calls the rule creates, so calls already inline in your source stay inline. Run a chain-collapsing formatter after Rector if you prefer single-line chains. # Diagnostics The skip log is **opt-in**. A default run still counts skips and reports the total, but writes no file: ``` [fluent-validation] 42 skip entries. Re-run with FLUENT_VALIDATION_RECTOR_VERBOSE=actionable and --clear-cache for details. ``` `FLUENT_VALIDATION_RECTOR_VERBOSE` takes three values, case-insensitive: | Value | Surfaces | |---|---| | unset | **off.** Always-actionable entries are counted, nothing is written | | `actionable` | **recommended.** Payloads needing manual migration, stale `@return` docblocks and the like, without structural noise | | `1` / `true` / `all` | **everything**, including the noise. `=1` stays an alias so existing CI scripts keep working | ```bash FLUENT_VALIDATION_RECTOR_VERBOSE=actionable vendor/bin/rector process --clear-cache ``` **`--clear-cache` matters.** Rector caches per-file results, and a file that bailed produced no transformation, so its skip entry is written once and the rule is not re-invoked on a cached run. Clear the cache (or delete `.cache/rector*`) to have every bail re-logged. The difference between the tiers is large in practice: a five-component Laravel 12 / Filament v5 app measured 110 entries at `=all` against 5 at `=actionable` on the same surface.
Log location, format, and why the flag is env-only Any opt-in tier writes `.cache/rector-fluent-validation-skips.log`, plus a `.session` sentinel coordinating truncation across parallel workers, and the end-of-run line points at it. `.cache/` matches Rector's own convention, so most projects already ignore it. The first line is a per-run header (package version, ISO-8601 UTC timestamp, verbose tier), which keeps diffs stable across releases in CI: ``` # laravel-fluent-validation-rector 1.9.0 — generated 2026-05-06T11:47:12Z # verbose tier: actionable [fluent-validation:skip] ... ``` The header is emitted even on a zero-entry run, so the file's existence is stable. **Env-only is deliberate.** The flag has to reach parallel workers, which are fresh PHP processes spawned via `proc_open`. Exported env inherits automatically; an in-process `putenv()` would not. **The sink is a file for the same reason.** Rector's `withParallel(...)` executor does not forward worker STDERR to the parent, so a line written with `fwrite(STDERR, ...)` from a worker vanishes on parallel runs, which is the default. A file survives worker death and can be read after the run. Worth knowing if you write your own Rector rules: `withParallel()` plus STDERR means silent data loss.
# Parity harness A few rectors change which Laravel rule object handles validation at runtime. The functional suite proves the source-to-source AST shape; the parity harness under `tests/Parity/` proves the resulting rule sets produce equivalent error bags when Laravel runs them. **In scope**, because semantics may change: - `SimplifyRuleWrappersRector` promotes `field()->rule('accepted')` to typed chains. - `GroupWildcardRulesToEachRector` folds wildcard siblings into `each(...)`. - `PromoteFieldFactoryRector` rewrites `field()->required()->rule('string')` to `string()->required()`. The pure-refactor rectors ship structural coverage only; their transformations do not change which rule class handles validation. ## Writing a fixture Each lives at `tests/Parity/Fixture//.php` and returns: ```php return [ 'rules_before' => ['field' => 'pre-rector-rule-shape'], 'rules_after' => ['field' => FluentRule::typed()->...], 'payloads' => [ 'descriptive name' => ['field' => 'value-to-test'], ], // only when the divergence is intentional: 'allowed_divergences' => [ 'descriptive name' => [ 'category' => DivergenceCategory::ImplicitTypeConstraint, 'rationale' => 'why this one is acceptable', ], ], ]; ``` The harness validates each payload against both rule sets and diffs the error bags. Outcomes: `MATCH`, `BEFORE_REJECTS_AFTER_PASSES`, `AFTER_REJECTS_BEFORE_PASSES`, `BOTH_REJECT_DIFFERENT_MESSAGES`, `BOTH_REJECT_DIFFERENT_ORDER`, or `SKIPPED` for the database and closure denylist. ## Allowed divergences Some transformations legitimately change behaviour. `boolean()->accepted()` rejects the `'yes'` and `'on'` strings that bare `accepted` allows, because of boolean's implicit pre-check. Categorize with `DivergenceCategory`: | Category | Meaning | |---|---| | `ImplicitTypeConstraint` | the typed rule attaches a constraint the pre-rector form lacked | | `MessageKeyDrift` | same outcome, different message-key path | | `AttributeLabelDrift` | same outcome, `:attribute` renders differently | | `OrderDependentPipeline` | same messages, different per-field order | The category constrains the allowed runtime outcome, so a mismatched one fails the test, and the rationale lives beside the divergence. `tests/Parity/CoverageTest.php` asserts every in-scope rector has at least one fixture. A new semantics-changing rector has to extend that list and ship a fixture before it merges. # AI assistant integration The package ships an agent [skill](https://docs.claude.com/en/docs/claude-code/skills) at `resources/boost/skills/fluent-validation-rector/`. It carries what an agent needs to drive the migration rather than guess at it: the set lists and what each contains, the rule architecture, and the cross-rector configuration semantics. That includes the silent-partial-config trap, where configuring an allowlist on one of the two rectors that read it leaves the other running empty, with no error. With [`laravel/boost`](https://github.com/laravel/boost) installed the skill is discovered from the installed package: ```bash php artisan boost:install ``` Any Boost-compatible agent picks it up: Claude Code, Cursor, Copilot. ## What it changes Without the skill, an agent asked to "make rector treat my custom rule as fluent-compatible" typically writes one `withConfiguredRule(...)` call. That is a partial migration: `SimplifyRuleWrappersRector` then simplifies chains on the class while `UpdateRulesReturnTypeDocblockRector` quietly declines to narrow the docblocks that use it, or the reverse. The skill states the shared-instance pattern, so both calls come out of one `AllowlistedFactories`. It also stops the two mistakes this documentation keeps repeating, because an agent reads the skill before it reads a page: that `SIMPLIFY`, `POLISH` and `SCHEMA` are separate passes rather than something to bundle into `ALL`, and that a bail is a designed outcome to read in the [skip log](https://sandermuller.github.io/laravel-fluent-validation-rector/diagnostics), not a failure to work around. ## Reading the docs directly The published site also serves plain text for readers that are not browsers: | URL | Holds | |---|---| | [`/llms.txt`](https://sandermuller.github.io/laravel-fluent-validation-rector/llms.txt) | the index: the package's rules up front, then one line per page | | [`/llms-full.txt`](https://sandermuller.github.io/laravel-fluent-validation-rector/llms-full.txt) | every page in reading order, in one fetch | | any page URL plus `.md` | that page alone, without the HTML | # Rule reference Any rule can be registered on its own, without pulling in its set: ```php use SanderMuller\FluentValidationRector\Rector\ValidationArrayToFluentRuleRector; use SanderMuller\FluentValidationRector\Rector\ValidationStringToFluentRuleRector; return RectorConfig::configure() ->withRules([ ValidationStringToFluentRuleRector::class, ValidationArrayToFluentRuleRector::class, ]); ``` | Rule | Set | Does | |---|---|---| | [`InlineResolvableParentRulesRector`](https://sandermuller.github.io/laravel-fluent-validation-rector/converters) | `CONVERT`, in `ALL` | inlines a `...parent::rules()` spread when the parent is a plain return | | [`ValidationStringToFluentRuleRector`](https://sandermuller.github.io/laravel-fluent-validation-rector/converters) | `CONVERT`, in `ALL` | pipe-delimited strings → FluentRule chains | | [`ValidationArrayToFluentRuleRector`](https://sandermuller.github.io/laravel-fluent-validation-rector/converters) | `CONVERT`, in `ALL` | rule arrays, `Rule::` and `Password::` objects → FluentRule chains | | [`ConvertLivewireRuleAttributeRector`](https://sandermuller.github.io/laravel-fluent-validation-rector/livewire) | `CONVERT`, in `ALL` | Livewire `#[Rule]` / `#[Validate]` → a generated `rules()` | | [`GroupWildcardRulesToEachRector`](https://sandermuller.github.io/laravel-fluent-validation-rector/grouping) | `GROUP`, in `ALL` | flat wildcard and dotted keys → nested `each()` / `children()` | | [`AddHasFluentRulesTraitRector`](https://sandermuller.github.io/laravel-fluent-validation-rector/traits) | `TRAITS`, in `ALL` | adds `use HasFluentRules;` to FormRequests using FluentRule or declaring `schema()` | | [`AddHasFluentValidationTraitRector`](https://sandermuller.github.io/laravel-fluent-validation-rector/traits) | `TRAITS`, in `ALL` | adds the Livewire trait, plain or Filament variant | | [`PromoteFieldFactoryRector`](https://sandermuller.github.io/laravel-fluent-validation-rector/simplify) | `SIMPLIFY`, **not** in `ALL` | `field()->rule('max:61')` → `string()->max(61)` | | [`SimplifyFluentRuleRector`](https://sandermuller.github.io/laravel-fluent-validation-rector/simplify) | `SIMPLIFY`, **not** in `ALL` | factory shortcuts, `between()`, redundant-type cleanup | | [`SimplifyRuleWrappersRector`](https://sandermuller.github.io/laravel-fluent-validation-rector/simplify) | `SIMPLIFY`, **not** in `ALL` | `->rule('in:a,b')` and friends → native typed methods | | [`InlineMessageParamRector`](https://sandermuller.github.io/laravel-fluent-validation-rector/simplify) | `SIMPLIFY`, **not** in `ALL` | `->message()` / `->messageFor()` → inline `message:` param | | [`UpdateRulesReturnTypeDocblockRector`](https://sandermuller.github.io/laravel-fluent-validation-rector/polish) | `POLISH`, **not** in `ALL` | narrows `@return` on pure-fluent `rules()` | | [`ConvertToFluentSchemaRector`](https://sandermuller.github.io/laravel-fluent-validation-rector/schema) | `SCHEMA`, **not** in `ALL` | `rules()` of `FluentRule::` chains → a `schema(FluentSchema $rules)` builder | The frozen public surface (symbols, wire keys, behaviour) is in [`PUBLIC_API.md`](https://github.com/SanderMuller/laravel-fluent-validation-rector/blob/main/PUBLIC_API.md). # Detection and limitations ## Detected without configuration The converters find rules-shaped methods by content signature: a string-keyed `return [...]` whose values include a recognized rule string, a `Rule::*()` call, a FluentRule chain, or a constructor-form rule object. No config needed for any of these: - `Validator::validate(...)` and the global `validator(...)` helper, when prefixed with `\` or in the global namespace. - Custom-named rules methods (`editorRules()`, `rulesWithoutPrefix()`) on classes that qualify: FormRequest descendants, trait users, Livewire components, and [`#[FluentRules]`](https://sandermuller.github.io/laravel-fluent-validation-rector/fluent-rules-attribute)-marked methods. - Dynamic args inside non-conditional tuples: `['max', $cond ? 15 : 20]`, `['between', config('a'), config('b')]`, `['max', $this->limit ?? 10]`. - `#[Validate]` args: the rule string, `as:` / `attribute:` (→ `->label()`), and `onUpdate: false` as a real-time opt-out marker. ## Left untouched - **A `SCHEMA` inheritance chain split across runs.** [`ConvertToFluentSchemaRector`](https://sandermuller.github.io/laravel-fluent-validation-rector/schema) rewrites a child's `parent::rules()` when the parent would convert if processed, and Rector gives a rule no way to confirm the parent's file is in this run. Process the chain together. - **Namespace-less files.** Classes at file root with no `namespace` are skipped by the grouping and trait rectors. Laravel projects normally namespace, so this rarely comes up. - **Rules built inside `withValidator()`.** That is a post-validation hook for adding errors via `$validator->after(...)`, not a rules definition. Imperative code stays. - **`Collection::put()->merge()->all()` pipelines.** Runtime-resolved, so not statically determinable. - **Multi-statement helper bodies.** Detection needs a single `return [...];`. A helper that assigns then returns stays untouched. Inline the return, or convert by hand. - **A ternary picking the rule NAME.** `['nullable', $flag ? 'email' : 'url']` is left alone. A `->when(cond, thenFn, elseFn)` conversion is tractable, but three codebase audits found near-zero usage (single digits across 100+ FormRequests), and the closure form loses the terseness people reach for ternaries to get. Use `Rule::when(...)`, or branch the array outside the ternary. Ternaries, calls, match and nullsafe fetches *as a rule's argument* convert fine. - **`#[Validate(..., onUpdate: true)]` and `translate: false`.** No FluentRule equivalent and no migration path; both land in the [skip log](https://sandermuller.github.io/laravel-fluent-validation-rector/diagnostics) for manual migration to Livewire's hooks or project config. `message:` is opt-in through [`MIGRATE_MESSAGES`](https://sandermuller.github.io/laravel-fluent-validation-rector/configuration#convertlivewireruleattributerector); with it off, those args are logged too.