PHPackages                             sandermuller/laravel-fluent-validation-rector - PHPackages - PHPackages  [Skip to content](#main-content)[PHPackages](/)[Directory](/)[Categories](/categories)[Trending](/trending)[Leaderboard](/leaderboard)[Changelog](/changelog)[Analyze](/analyze)[Collections](/collections)[Log in](/login)[Sign up](/register)

1. [Directory](/)
2. /
3. [Database &amp; ORM](/categories/database)
4. /
5. sandermuller/laravel-fluent-validation-rector

ActiveRector-extension[Database &amp; ORM](/categories/database)

sandermuller/laravel-fluent-validation-rector
=============================================

Rector rules for migrating Laravel validation to sandermuller/laravel-fluent-validation

1.3.0(4w ago)43.9k↑24.2%[1 PRs](https://github.com/SanderMuller/laravel-fluent-validation-rector/pulls)MITPHPPHP ^8.2CI passing

Since Apr 12Pushed 2d ago1 watchersCompare

[ Source](https://github.com/SanderMuller/laravel-fluent-validation-rector)[ Packagist](https://packagist.org/packages/sandermuller/laravel-fluent-validation-rector)[ Docs](https://github.com/sandermuller/laravel-fluent-validation-rector)[ RSS](/packages/sandermuller-laravel-fluent-validation-rector/feed)WikiDiscussions main Synced 1w ago

READMEChangelog (10)Dependencies (17)Versions (70)Used By (0)

Laravel Fluent Validation Rector
================================

[](#laravel-fluent-validation-rector)

[![Latest Version on Packagist](https://camo.githubusercontent.com/e466150153b63e2129b4804617448aaa8dacf3a7fc525e73fb9f9441cc538640/68747470733a2f2f696d672e736869656c64732e696f2f7061636b61676973742f762f73616e6465726d756c6c65722f6c61726176656c2d666c75656e742d76616c69646174696f6e2d726563746f722e7376673f7374796c653d666c61742d737175617265)](https://packagist.org/packages/sandermuller/laravel-fluent-validation-rector)[![GitHub Tests Action Status](https://camo.githubusercontent.com/dc47421970838b6683f1d8f8ddcd4520b372d663cd0cbb37481d69e3c9560bbe/68747470733a2f2f696d672e736869656c64732e696f2f6769746875622f616374696f6e732f776f726b666c6f772f7374617475732f73616e6465726d756c6c65722f6c61726176656c2d666c75656e742d76616c69646174696f6e2d726563746f722f72756e2d74657374732e796d6c3f6272616e63683d6d61696e266c6162656c3d7465737473267374796c653d666c61742d737175617265)](https://github.com/sandermuller/laravel-fluent-validation-rector/actions?query=workflow%3Arun-tests+branch%3Amain)[![GitHub PHPStan Action Status](https://camo.githubusercontent.com/f46913fdd4310f72bc1a2b31737739869197136ed77bc6bee4fa3fcf6f27435e/68747470733a2f2f696d672e736869656c64732e696f2f6769746875622f616374696f6e732f776f726b666c6f772f7374617475732f73616e6465726d756c6c65722f6c61726176656c2d666c75656e742d76616c69646174696f6e2d726563746f722f7068707374616e2e796d6c3f6272616e63683d6d61696e266c6162656c3d7068707374616e267374796c653d666c61742d737175617265)](https://github.com/sandermuller/laravel-fluent-validation-rector/actions?query=workflow%3Aphpstan+branch%3Amain)[![Total Downloads](https://camo.githubusercontent.com/2d6b994e1e28e0f165f90e1a2ff7ac70b9b1b16951f1a78d75121aa2fddf5c8c/68747470733a2f2f696d672e736869656c64732e696f2f7061636b61676973742f64742f73616e6465726d756c6c65722f6c61726176656c2d666c75656e742d76616c69646174696f6e2d726563746f722e7376673f7374796c653d666c61742d737175617265)](https://packagist.org/packages/sandermuller/laravel-fluent-validation-rector)[![License](https://camo.githubusercontent.com/df57da8a1f259711f4f580bfd9b26aee42937679c78b738d4801cebda9316dbb/68747470733a2f2f696d672e736869656c64732e696f2f7061636b61676973742f6c2f73616e6465726d756c6c65722f6c61726176656c2d666c75656e742d76616c69646174696f6e2d726563746f722e7376673f7374796c653d666c61742d737175617265)](LICENSE.md)

Rector rules for migrating 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 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
--------

[](#contents)

**Getting started**

- [Installation](#installation)
- [Quick start](#quick-start)
- [Rules shipped](#rules-shipped) — what gets converted and what stays

**Usage**

- [Sets](#sets) — mix and match subsets of the migration pipeline
- [Individual rules](#individual-rules) — when you need one specific conversion

**Operation**

- [Formatter integration](#formatter-integration) — what the rector emits and how Pint / PHP-CS-Fixer finish the job
- [Diagnostics](#diagnostics) — skip log + verbosity tiers
- [Parity](#parity) — runtime-equivalence harness for semantics-changing rectors
- [`#[FluentRules]` opt-in](#opting-in-fluentrules-attribute) — per-method opt-in attribute

**Reference**

- [Public API](PUBLIC_API.md) — frozen surface (symbols, wire keys, behavior)
- [Auto-detected](#auto-detected-no-config-needed) — supported shapes you don't need to configure
- [Known limitations](#known-limitations)
- [Testing](#testing) · [Changelog](#changelog) · [Contributing](#contributing) · [Security](#security) · [License](#license)

Installation
------------

[](#installation)

```
composer require --dev sandermuller/laravel-fluent-validation-rector
```

**Requirements**: PHP 8.3+, Rector 2.4+, [`sandermuller/laravel-fluent-validation`](https://github.com/sandermuller/laravel-fluent-validation) ^1.27.2.

If you're on an older fluent-validation:

fluent-validationPin rector to1.17 – 1.19`^0.8`1.20+`^1.0` (latest)Quick start
-----------

[](#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](#sets) or register [individual rules](#individual-rules).

Rules shipped
-------------

[](#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`)

[](#converters-set-convert)

#### `ValidationStringToFluentRuleRector`

[](#validationstringtofluentrulerector)

Converts pipe-delimited rule strings (`'required|string|max:255'`) to fluent chains.

- **Where it fires**: FormRequest `rules()`, `$request->validate()`, `Validator::make()`, plus `RuleSet::from([...])` wrappers anywhere in PHP source (FormRequest `rules(): RuleSet` returns, action methods, services, controllers, jobs — added 1.1.0). The wrapper itself stays intact; only the inner array converts.

#### `ValidationArrayToFluentRuleRector`

[](#validationarraytofluentrulerector)

Converts array-based rules (`['required', 'string', Rule::unique(...)]`), including `Rule::` objects, `Password::min()` chains, conditional tuples, closures, and custom rule objects. Same surface as the string converter — FormRequest `rules()`, `$request->validate()`, `Validator::make()`, plus `RuleSet::from([...])` wrappers anywhere in PHP source.

Conditional tuples and dynamic-arg handling- **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())`
- **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 $field` overload ambiguity.
- **`Rule::` presence conditionals**: `Rule::requiredIf` / `requiredUnless` / `excludeIf` / `excludeUnless` / `prohibitedIf` / `prohibitedUnless` with a **closure or bool-literal** argument convert to the dedicated fluent method (`Rule::requiredIf(fn () => $this->isAdmin())` → `->requiredIf(fn () => $this->isAdmin())`); matching is case-insensitive. Any other argument shape (a variable or string the fluent method would read as a *field name*) is left as the native array.
- **Composite `Rule::` builders bail**: `Rule::when()` / `Rule::unless()` (`ConditionalRules`) and `Rule::forEach()` (`NestedRules`) have no faithful fluent equivalent — the field's native array is preserved untouched.

#### `InlineResolvableParentRulesRector`

[](#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. Runs first in `CONVERT`.

Supported shapes and bail conditions- **Handles**:
    - `...parent::rules()` when the parent is a plain `return [...];`
    - `...$base` when `$base` is the method's only top-level assignment and its RHS is a literal array or `parent::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.

#### `ConvertLivewireRuleAttributeRector`

[](#convertlivewireruleattributerector)

Strips Livewire `#[Rule('...')]` / `#[Validate('...')]` property attributes and generates a `rules(): array` method. See [config keys](#convertlivewireruleattributerector-config) for `KEY_OVERLAP_BEHAVIOR`, `MIGRATE_MESSAGES`, `PRESERVE_REALTIME_VALIDATION`.

Supported shapes and bail conditions- **Handles**:
    - String, list-array, and keyed-array shapes. `#[Validate(['todos' => 'required', 'todos.*' => '...'])]` expands into one `rules()` entry per key.
    - Constructor-form rule objects (`new Password(8)`, `new Unique('users')`, `new Exists('roles')`) lower to `FluentRule::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 so `wire:model.live` real-time validation survives conversion. Opt out via [`PRESERVE_REALTIME_VALIDATION => false`](#convertlivewireruleattributerector-config).
- **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`-trait compose conflict (an ancestor uses the trait AND the child carries `#[Rule]` / `#[Validate]` — the trait's `getRules()` reads only `rules(): array` so the attribute is silently ignored at runtime, and rector-side conversion would override the parent's `rules()` and drop parent-owned fields). Each bail logged to the skip file (see [Diagnostics](#diagnostics)). Direct trait use on the class itself is **not** a bail — the rector merges the attribute rule into a local `rules()` array (or installs one), since neither failure mode applies in that shape.

### Grouping (set `GROUP`)

[](#grouping-set-group)

#### `GroupWildcardRulesToEachRector`

[](#groupwildcardrulestoeachrector)

Folds flat wildcard and dotted keys into nested `each()` / `children()` calls. Applies to FormRequests and Livewire components alike.

Bail conditions and edge-case handling- **Bails on** (each emits a specific skip-log entry under [`=actionable`](#diagnostics)):
    - Wildcard group has non-FluentRule entries — `'items' => ['required', ...]` next to `'items.*' => FluentRule::...`.
    - Parent rule's factory doesn't support `each()` / `children()` — only `FluentRule::array()` and `FluentRule::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.
- **Notes**:
    - On Livewire, the `HasFluentValidation` trait's `getRules()` 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 nested `required` children 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 returning `RuleSet::from([...])` (the canonical `sandermuller/laravel-fluent-validation` shape) are folded by descending into the array argument. The `RuleSet::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.

### Traits (set `TRAITS`)

[](#traits-set-traits)

#### `AddHasFluentRulesTraitRector`

[](#addhasfluentrulestraitrector)

Adds `use HasFluentRules;` to FormRequests that use FluentRule.

#### `AddHasFluentValidationTraitRector`

[](#addhasfluentvalidationtraitrector)

Adds the fluent-validation trait to Livewire components that use FluentRule. Picks the plain or Filament variant based on direct trait usage.

Variant selection and bail conditions- **Variant picking**:
    - Plain Livewire component → `HasFluentValidation`.
    - Filament's `InteractsWithForms` (v3/v4) or `InteractsWithSchemas` (v5) used **directly** on the class → `HasFluentValidationForFilament` + a 4-method `insteadof` block.
    - Wrong variant already directly on a class → swaps to the right one and drops the orphaned import.
- **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`)

[](#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`

[](#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. Unblocks `SimplifyRuleWrappersRector`'s next pass — `FluentRule::field()->rule('max:61')` becomes `FluentRule::string()->max(61)` instead of staying on the escape hatch. Runs first in `SIMPLIFY`.

Promotion targets, bail conditions, semantic notes- **Also promotes**: `FluentRule::string()->rule(Password::default())` / `->rule(Email::default())` → `FluentRule::password()` / `::email()` (same zero-arg source, single Password/Email match, no Conditionable hops).
- **Also promotes**: `field()->required()->rule('accepted')` → `FluentRule::accepted()->required()` (and the `declined` analog). The dedicated `AcceptedRule` / `DeclinedRule` factories seed the corresponding constraint in their constructors, so the `->rule('')` hop is spliced out. Distinct from intersection-based promotion: promotion to `boolean()` stays blocked because boolean's implicit constraint rejects `"yes"` / `"on"` / `"true"` (`accepted`) and `"no"` / `"off"` / `"false"` (`declined`). Triggers only when the chain has exactly one `->rule(...)` payload and the remaining hops all exist on the dedicated target class.
- **Bails on**:
    - Conditionable hops in the chain.
    - Chains whose compatible-class intersection isn't a singleton.
    - `field()->rule('accepted'|'declined')` chains carrying additional `->rule(...)` payloads — the dedicated factory has no typed methods for further constraints, so the escape hatch stays.
- **Semantic note**: `StringRule` adds Laravel's implicit `string` rule (likewise `numeric` for `NumericRule`); `FieldRule` adds neither. Promoting therefore changes validation behavior on non-string inputs. Intent matches in nearly all `max(N)` cases, but review the diff.

#### `SimplifyFluentRuleRector`

[](#simplifyfluentrulerector)

Cleans up FluentRule chains after migration: factory shortcuts (`string()->url()` → `url()`), `->label()` folded into factory args, `min()` + `max()` → `between()`, redundant type removal.

Bail conditions- `min()` + `max()` fold when either method carries `messageFor('min'/'max')` or a positional `message()`. 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`

[](#simplifyrulewrappersrector)

Rewrites escape-hatch `->rule(...)` calls into native typed-rule methods. Runs after `SimplifyFluentRuleRector` so factory shortcuts apply first.

Rewrite table, conditional-tuple handling, receiver inference- **Handles**:

    Rule familyReceiversNotes`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`Laravel's `size:` renamed in fluent-validation per `TypedBuilderHint``enum``HasEmbeddedRules` consumerstyped-rule allowlistLiteral-zero comparison helpers`NumericRule``gt:0` → `->positive()`, `gte:0` → `->nonNegative()`, `lt:0` → `->negative()`, `lte:0` → `->nonPositive()`. Non-zero literals + field refs stay escape.Zero-arg string tokenstyped 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 C `required_if_accepted` and Category D `exclude_with` stay as escape hatch.
- **`Rule::` facade conditional rules**: `->rule(Rule::requiredIf($cond))` → `->requiredIf($cond)`, plus the `requiredUnless` / `excludeIf` / `excludeUnless` / `prohibitedIf` / `prohibitedUnless` siblings. The facade form takes a single `Closure|bool` condition, passed through verbatim. Bails on a multi-arg call (not valid facade usage) and on a literal-`null` condition (valid Laravel — normalizes to `false` — but `->requiredIf(null)` would `TypeError` against the `Closure|bool|string` signature). Named args on any facade call (`Rule::in(values: [...])`) also bail, since param names differ between the facade and the fluent builder.
- **Receiver-type inference**: walks the chain back to the `FluentRule::*()` factory. Steps through `Conditionable` proxy hops (`->when(...)` / `->unless(...)` / `->whenInput(...)`) when the closure body is a bare-return / no-return / `fn ($r) => $r` identity. Proxy hops with other closure shapes bail.
- **Bails on**: variable receivers, methods absent from the resolved typed-rule class.

#### `InlineMessageParamRector`

[](#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).

Rewrite predicates and skip categories- **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.')`.
- **Skip categories** (each emits a user-facing log entry):

    CategoryExamplesWhyVariadic-trailing`requiredWith` / `contains`inline binds to wrong slotComposite`digitsBetween` / `DateRule::between` / `ImageRule::dimensions`inline binds to last sub-ruleMode-modifier`EmailRule::strict` / `PasswordRule::letters`don't call `addRule`Deferred-key factories`date` / `dateTime`L11/L12-divergent `Password``getFromLocalArray` shortRule lookup is L12+ onlytemplate lists `password.letters` / `password.mixed` sub-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`)

[](#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`

[](#updaterulesreturntypedocblockrector)

Narrows the `@return` PHPDoc annotation on `rules()` methods from the wide `array` union down to `array` 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. Run as a separate pass after `CONVERT` stabilizes.

Qualifying classes, narrow conditions, skip conditions- **Qualifying classes**: `FormRequest` subclasses (anywhere in the ancestor chain, aliased imports included) and classes using `HasFluentRules` / `HasFluentValidation` / `HasFluentValidationForFilament` directly 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 `): ?array` or unkeyed items.
- Rector's multi-pass convergence means it eventually fires on the final shape, but a single-invocation rector run that mixes `CONVERT` + `POLISH` may require a second invocation if any file had string-rule items mid-convert.

### Opting in: `#[FluentRules]` attribute

[](#opting-in-fluentrules-attribute)

`#[FluentRules]` is a per-method opt-in attribute (defined in [`sandermuller/laravel-fluent-validation`](https://github.com/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's `rulesWithoutPrefix()`. 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 manipulate `parent::rules()` with array merges. The attribute is your assertion of audit-safety; the package's safety guard for abstract classes is bypassed for the attributed `rules()` 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 unattributed `rules()` itself.

Scoping rules + what `#\[FluentRules\]` does NOT lift**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's `rules()` 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 but `GroupWildcardRulesToEachRector` skips 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 postprocesses `rulesWithoutPrefix()` 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]` on `casts()`, `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
----

[](#sets)

SetRules`ALL``CONVERT` + `GROUP` + `TRAITS` (the full migration pipeline)`CONVERT`[`InlineResolvableParentRulesRector`](#inlineresolvableparentrulesrector), [`ValidationStringToFluentRuleRector`](#validationstringtofluentrulerector), [`ValidationArrayToFluentRuleRector`](#validationarraytofluentrulerector), [`ConvertLivewireRuleAttributeRector`](#convertlivewireruleattributerector)`GROUP`[`GroupWildcardRulesToEachRector`](#groupwildcardrulestoeachrector)`TRAITS`[`AddHasFluentRulesTraitRector`](#addhasfluentrulestraitrector), [`AddHasFluentValidationTraitRector`](#addhasfluentvalidationtraitrector)`SIMPLIFY`[`PromoteFieldFactoryRector`](#promotefieldfactoryrector), [`SimplifyFluentRuleRector`](#simplifyfluentrulerector), [`SimplifyRuleWrappersRector`](#simplifyrulewrappersrector), [`InlineMessageParamRector`](#inlinemessageparamrector) — post-migration cleanup, run as a separate pass after verifying the initial conversion`POLISH`[`UpdateRulesReturnTypeDocblockRector`](#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
----------------

[](#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):

RuleSet (opt-in)Purpose[`InlineResolvableParentRulesRector`](#inlineresolvableparentrulesrector)`CONVERT` (included in `ALL`)inline `...parent::rules()` spread when parent is plain `return [...]`[`ValidationStringToFluentRuleRector`](#validationstringtofluentrulerector)`CONVERT` (included in `ALL`)pipe-delimited rule strings → FluentRule chains[`ValidationArrayToFluentRuleRector`](#validationarraytofluentrulerector)`CONVERT` (included in `ALL`)array-based rules + `Rule::`/`Password::` objects → FluentRule chains[`ConvertLivewireRuleAttributeRector`](#convertlivewireruleattributerector)`CONVERT` (included in `ALL`)Livewire `#[Rule]` / `#[Validate]` → generated `rules()` method[`GroupWildcardRulesToEachRector`](#groupwildcardrulestoeachrector)`GROUP` (included in `ALL`)flat wildcard/dotted keys → nested `each()` / `children()`[`AddHasFluentRulesTraitRector`](#addhasfluentrulestraitrector)`TRAITS` (included in `ALL`)adds `use HasFluentRules;` to FormRequests that use FluentRule[`AddHasFluentValidationTraitRector`](#addhasfluentvalidationtraitrector)`TRAITS` (included in `ALL`)adds Livewire trait (plain or Filament variant) to Livewire components[`PromoteFieldFactoryRector`](#promotefieldfactoryrector)`SIMPLIFY` (**not** in `ALL`)`FluentRule::field()->rule('max:61')` → `FluentRule::string()` when wrappers narrow to one typed subclass[`SimplifyFluentRuleRector`](#simplifyfluentrulerector)`SIMPLIFY` (**not** in `ALL`)factory shortcuts, `->between()`, redundant-type cleanup[`SimplifyRuleWrappersRector`](#simplifyrulewrappersrector)`SIMPLIFY` (**not** in `ALL`)`->rule('in:a,b')` / `->rule(Rule::in([...]))` / `->rule('size:N')` → native typed-rule methods (`->in([...])`, `->exactly(N)`, etc.)[`InlineMessageParamRector`](#inlinemessageparamrector)`SIMPLIFY` (**not** in `ALL`)`->message('x')` / `->messageFor('key', 'x')` on factories + rule methods → inline `message:` named param (requires fluent-validation ^1.20)[`UpdateRulesReturnTypeDocblockRector`](#updaterulesreturntypedocblockrector)`POLISH` (**not** in `ALL`)narrow `@return` on pure-fluent `rules()` to `FluentRuleContract`### Configurable rules

[](#configurable-rules)

Four rules accept configuration via `withConfiguredRule()`.

#### `ConvertLivewireRuleAttributeRector` config

[](#convertlivewireruleattributerector-config)

KeyTypeDefaultWhat 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'` → `'' => 'X'`; array `message: ['rule' => 'X']` → `'.' => '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'` skips the whole class. `'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()` accepted; anything else forces classwide bail.#### `SimplifyRuleWrappersRector` config

[](#simplifyrulewrappersrector-config)

KeyTypeDefaultWhat it does`TREAT_AS_FLUENT_COMPATIBLE``list``[]`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

[](#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.

Note

**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 both `SimplifyRuleWrappersRector` and `UpdateRulesReturnTypeDocblockRector`, 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

[](#addhasfluentrulestraitrector-config)

KeyTypeDefaultWhat it does`BASE_CLASSES``list``[]`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)

[](#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.

RectorDTOShared 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
---------------------

[](#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.

1. Imports are inserted at prepend position (not alphabetical). The `ordered_imports` fixer resolves.
2. Unused imports may be left in place (e.g. a `Livewire\Attributes\Rule` import after the attribute is stripped). The `no_unused_imports` fixer resolves.
3. Generated `@return` docblocks emit `Illuminate\Contracts\Validation\ValidationRule` as a fully-qualified reference. The `fully_qualified_strict_types` fixer hoists it to a `use` statement + 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`](https://mlocati.github.io/php-cs-fixer-configurator/#version:3.0%7Cfixer:method_chaining_indentation) fixer (Pint / PHP-CS-Fixer) reflows them after Rector runs.

Diagnostics
-----------

[](#diagnostics)

The skip log is **opt-in**. 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):

ValueSurfacesunset / 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
```

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*`).

Log file location, format, and parallel-worker rationaleEnv-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 1.2.1 — generated 2026-05-06T11: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.

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.

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`](#convertlivewireruleattributerector-config) 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
------

[](#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` — promotes `field()->rule('accepted')` to typed factory chains.
- `GroupWildcardRulesToEachRector` — folds wildcard sibling keys into `each(...)`.
- `PromoteFieldFactoryRector` — rewrites `field()->required()->rule('string')` to `string()->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//.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, `:attribute` substitution 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.

Auto-detected (no config needed)
--------------------------------

[](#auto-detected-no-config-needed)

The converters 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. No consumer 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()`, etc.) on classes that qualify as rules-bearing — FormRequest descendants, fluent-validation-trait users, Livewire components, `#[FluentRules]`-marked methods.
- **Dynamic args inside non-conditional tuples** — `['max', $cond ? 15 : 20]`, `['between', config('a'), config('b')]`, `['max', $this->limit ?? 10]` — convert via the permissive emittable-arg path (see [`ValidationArrayToFluentRuleRector`](#validationarraytofluentrulerector)).
- **`#[Validate]` attribute args**: rule string, `as:` / `attribute:` label (→ `->label()`), and `onUpdate: false` (consumed as a real-time-validation opt-out marker).

Known limitations
-----------------

[](#known-limitations)

- **Namespace-less files.** Classes at the file root without a `namespace` are 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 literal `put()` chain ending in `->all()`) gathers consumer demand.
- **Multi-statement helper bodies.** Auto-detection requires a single-statement `return [...];` shape. Helpers like `private function buildRules() { $rules = [...]; return $rules; }` stay untouched. Inline the return or convert by hand.
- **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. Use `Rule::when(...)` or branch the rules array outside the ternary instead. (Ternaries / function calls / match / nullsafe fetches *as a rule's argument* convert fine — see [Auto-detected](#auto-detected-no-config-needed).)
- **`#[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. **`message:` is opt-in**: enable [`MIGRATE_MESSAGES`](#convertlivewireruleattributerector-config) to migrate string and array `message:` args into a generated `messages(): array` method alongside `rules()`. With `MIGRATE_MESSAGES` off (default), `message:` args also land in the skip log for manual migration.

Testing
-------

[](#testing)

```
composer test          # vendor/bin/pest
composer qa            # format → rector → phpstan → test
```

The suite runs against [Orchestra Testbench](https://github.com/orchestral/testbench) — no host Laravel app required.

Changelog
---------

[](#changelog)

Release notes live in [CHANGELOG.md](CHANGELOG.md) and on the [GitHub releases page](https://github.com/sandermuller/laravel-fluent-validation-rector/releases).

Contributing
------------

[](#contributing)

See [CONTRIBUTING.md](CONTRIBUTING.md) for the dev setup, test workflow, and PR conventions.

Security
--------

[](#security)

Disclose vulnerabilities privately — see [SECURITY.md](SECURITY.md) for the GitHub Security Advisories link or PGP-accepting email.

Credits
-------

[](#credits)

- [Sander Muller](https://github.com/sandermuller)
- [All contributors](https://github.com/sandermuller/laravel-fluent-validation-rector/contributors)

License
-------

[](#license)

MIT — see [LICENSE.md](LICENSE.md).

###  Health Score

52

—

FairBetter than 96% of packages

Maintenance97

Actively maintained with recent releases

Popularity28

Limited adoption so far

Community7

Small or concentrated contributor base

Maturity61

Established project with proven stability

 Bus Factor1

Top contributor holds 100% of commits — single point of failure

How is this calculated?**Maintenance (25%)** — Last commit recency, latest release date, and issue-to-star ratio. Uses a 2-year decay window.

**Popularity (30%)** — Total and monthly downloads, GitHub stars, and forks. Logarithmic scaling prevents top-heavy scores.

**Community (15%)** — Contributors, dependents, forks, watchers, and maintainers. Measures real ecosystem engagement.

**Maturity (30%)** — Project age, version count, PHP version support, and release stability.

###  Release Activity

Cadence

Every ~0 days

Total

69

Last Release

29d ago

Major Versions

0.22.3 → 1.0.02026-04-29

### Community

Maintainers

![](https://www.gravatar.com/avatar/abeb7bd51fd77656b4133bdcdf4eca99cc8b495b83ca72f63674ff15f2b72a39?d=identicon)[SanderMuller](/maintainers/SanderMuller)

---

Top Contributors

[![SanderMuller](https://avatars.githubusercontent.com/u/9074391?v=4)](https://github.com/SanderMuller "SanderMuller (225 commits)")

---

Tags

laravelvalidationmigrationfluentrector

###  Code Quality

TestsPest

Static AnalysisPHPStan

Code StyleLaravel Pint

Type Coverage Yes

### Embed Badge

![Health badge](/badges/sandermuller-laravel-fluent-validation-rector/health.svg)

```
[![Health](https://phpackages.com/badges/sandermuller-laravel-fluent-validation-rector/health.svg)](https://phpackages.com/packages/sandermuller-laravel-fluent-validation-rector)
```

###  Alternatives

[ssch/typo3-rector

Instant fixes for your TYPO3 PHP code by using Rector.

2603.0M381](/packages/ssch-typo3-rector)[rector/rector-src

Instant Upgrade and Automated Refactoring of any PHP code

136400.8k14](/packages/rector-rector-src)[mrpunyapal/rector-pest

Rector upgrade rules for Pest - refactoring and best practices for Pest testing framework

6455.5k49](/packages/mrpunyapal-rector-pest)[codengine/laravel-custom-migrations

Custom Migrations for Laravel

131.3k](/packages/codengine-laravel-custom-migrations)

PHPackages © 2026

[Directory](/)[Categories](/categories)[Trending](/trending)[Changelog](/changelog)[Analyze](/analyze)
