sandermuller / laravel-fluent-validation-phpstan
PHPStan rules for sandermuller/laravel-fluent-validation
Package info
github.com/SanderMuller/laravel-fluent-validation-phpstan
Type:phpstan-extension
pkg:composer/sandermuller/laravel-fluent-validation-phpstan
Requires
- php: ^8.3
- phpstan/phpstan: ^2.1.8
Requires (Dev)
- laravel/pao: ^1.0
- laravel/pint: ^1.21
- nikic/php-parser: ^5.4
- nunomaduro/collision: ^8.0
- orchestra/testbench: ^9.0 || ^10.11 || ^11.0
- phpstan/extension-installer: ^1.4
- phpstan/phpstan-deprecation-rules: ^2.0
- phpstan/phpstan-phpunit: ^2.0
- phpstan/phpstan-strict-rules: ^2.0
- phpunit/phpunit: ^11.5.3 || ^12.5.22
- rector/rector: ^2.0
- sandermuller/boost-skills: ^1.9
- sandermuller/laravel-fluent-validation: ^1.33.0
- sandermuller/package-boost-php: ^0.16.1
- spaze/phpstan-disallowed-calls: ^4.10
- symplify/phpstan-extensions: ^12.0.1
- tomasvotruba/cognitive-complexity: ^1.0
- tomasvotruba/type-coverage: ^2.5.1
Suggests
None
Provides
None
Conflicts
None
Replaces
None
- dev-main
- 0.5.0
- 0.4.1
- 0.4.0
- 0.3.0
- 0.2.0
- 0.1.0
- dev-ci/remove-phpstan-pin
- dev-chore/changelog-0.5.0
- dev-feat/form-request-validated-shape
- dev-ci/pin-phpstan-for-rector
- dev-chore/changelog-0.4.1
- dev-fix/required-presence-on-unreadable-values
- dev-chore/changelog-0.4.0
- dev-feat/ruleset-from-validated-shape
- dev-chore/changelog-0.2.0
This package is auto-updated.
Last update: 2026-10-29 09:00:41 UTC
README
PHPStan rules that flag misuse of sandermuller/laravel-fluent-validation in consumer projects.
Requirements
- PHP 8.3 or higher
- PHPStan 2.1 or higher
sandermuller/laravel-fluent-validation(any version that shipsFluentRule::array(),FluentRule::list(), andArrayRule::each/max/between/exactly/rule—^1.19and up)
Compatibility
laravel-fluent-validation-phpstan |
sandermuller/laravel-fluent-validation |
|---|---|
^0.1 |
^1.19 |
^0.4 |
^1.19, and ^1.36 for the RuleSet::from() validated shape |
Installation
composer require --dev sandermuller/laravel-fluent-validation-phpstan
With phpstan/extension-installer the rules auto-register. Otherwise include the extension in your phpstan.neon:
includes: - vendor/sandermuller/laravel-fluent-validation-phpstan/extension.neon
Rules
NoUnboundedArrayEachRule
Flags FluentRule::array()->each(...) (and FluentRule::list()->each(...)) chains that have no upper-bound constraint. An unbounded array validated with per-item rules — especially ->exists(), closure rules, or anything that hits the database — scales linearly with payload size. A caller that sends 10,000 items runs 10,000 per-item checks. Left unchecked, this is a classic N+1 / DoS footgun.
The rule fires on any ->each(...) call whose receiver is an ArrayRule and whose chain has no ->max(N), ->between(N, M), ->exactly(N), and no predefined key whitelist. A rule-string form (->rule('max:10'), ->rule(['max', 10]), ->rule('size:3'), ->rule('between:1,5')) also counts as a bound.
namespace App\Http\Requests; use SanderMuller\FluentValidation\FluentRule; // reported — no upper bound, per-item DB hit runs once per payload entry FluentRule::array()->each(FluentRule::string()->exists('users', 'id')); // fine — explicit ceiling on array size FluentRule::array()->max(100)->each(FluentRule::string()->exists('users', 'id')); // fine — trailing bound, chain still classified as bounded FluentRule::array()->each(FluentRule::string()->exists('users', 'id'))->max(100); // fine — keys constrained at the factory, array shape is bounded FluentRule::array(['email', 'name'])->each(FluentRule::string()); // fine — rule-string escape hatch FluentRule::array()->rule('max:50')->each(FluentRule::string());
Identifier: sandermuller.fluentValidation.noUnboundedArrayEach (stable across versions — suppressions written against this identifier will not rot across minor/patch releases).
Message:
FluentRule::array()->each(...)(orFluentRule::list()->each(...)) has no upper bound. Unbounded arrays combined with per-item rules (e.g.exists, closures) can generate large query counts on big payloads. Add->max(N),->between(N, M),->exactly(N), or constrain keys viaFluentRule::array([...]).
Configuration
parameters: fluentValidationNoUnboundedArrayEach: namespaces: - App # default excludeNamespaces: [] boundingMethods: - max # defaults - between - exactly boundingRuleStringPrefixes: - max # defaults - size - between
namespaces restricts analysis to chains declared in the listed namespace prefixes (matched against Scope::getNamespace(), str_starts_with-style). excludeNamespaces subtracts; useful for bootstrap / response contract code where a raw each(...) without a bound is known-safe.
boundingMethods lists fluent methods that count as an upper bound when they appear in the chain. Extend this list when you've registered ArrayRule::macro('boundedBy', ...) or similar — e.g. add boundedBy so chains using your macro are treated as bounded. Values are lowercase-insensitive (PHP method calls are case-insensitive at runtime; the rule matches the same way).
boundingRuleStringPrefixes lists Laravel validation-rule prefixes that count as an upper bound when seen inside ->rule(...) or ->rule([...]) hops. Extend when your codebase uses a bound-equivalent custom prefix (for example, an in:... allowlist where the cardinality of the in list is itself the cap, or a project-specific MyRule::cap:N).
Escape hatches
Single line, when the payload is bounded by something the rule can't see (a paginator cap, an upstream form, a feature-flag check):
// @phpstan-ignore-next-line sandermuller.fluentValidation.noUnboundedArrayEach FluentRule::array()->each(FluentRule::string()->exists('users', 'id'));
Per project, when a whole subsystem legitimately deals in small, bounded-by-convention arrays:
parameters: ignoreErrors: - identifier: sandermuller.fluentValidation.noUnboundedArrayEach paths: - app/Console/Commands/*
PHPStan's default reportUnmatchedIgnoredErrors: true will turn stale identifier ignores into errors when the underlying call is removed or bounded — keep that default on, so suppressions don't silently hide real fixes.
Known limitations
- Helper-method origin.
$this->makeBuilder()->each(...)— when theArrayRulecomes from a method return, the factory type is known but the chain that built it isn't visible. The rule skips this case (zero-false-positive policy). If you want the rule to fire, inline the factory call or add the bound at the helper's definition. - Macro-registered bounders.
ArrayRule::macro('boundedBy', fn (int $n) => $this->max($n))is invisible to static analysis — the rule will flag chains using your custom bounder unless you add its name toboundingMethodsin yourphpstan.neon(see Configuration above). - Dynamic keys.
FluentRule::array($keysVar)->each(...)is only flagged when the chain also lacks amax/between/exactlyhop; PHPStan can't prove the dynamic$keysVaris non-empty at analysis time. - Unknown-origin receivers.
$this->builder->each(...)where$this->builderis an injectedArrayRuleproperty is not flagged. Same rationale as helper-method origin.
Out of scope
ArrayRule::children(...)— children are a fixed set, no unbounded risk.- Per-item DB-heavy rules specifically (
->exists(), closures). The MVP flags any unboundedeach(...)regardless of what's inside, to avoid false negatives. Suppress via identifier ignore for cheap-per-item cases. - Autofix. There is no unambiguous "correct" bound; the rule stays advisory.
Type extensions
Beyond the rules, the package teaches PHPStan to infer types the runtime API can't declare. No configuration; registering extension.neon is enough.
rules() on classes using HasFluentRules
HasFluentRules::rules() is declared array<string, mixed>|RuleSet, because the trait passes schema()'s value straight through and a schema() may legitimately return either. That union is honest at runtime but too wide at each call site, so code that was clean before migrating rules() to schema() starts failing:
final class SessionStartedRequest extends BasePlayerRequest { public function rules(): RuleSet { // Cannot call method merge() on array<string, mixed>|RuleSet. return parent::rules()->merge(['position' => FluentRule::integer()->required()]); } }
The extension resolves rules() to the concrete class's schema() return type — RuleSet above, so merge() resolves. It applies to instance calls and to parent::/self::/static:: calls, and mirrors the trait's own gate: a schema() whose first parameter isn't a FluentSchema is an unrelated method and is left alone, as is a class that declares its own rules().
RuleSet::from([...]) validated shape
With laravel-fluent-validation 1.36 or later, whose RuleSet is generic, RuleSet::from() called with a literal rules array resolves to RuleSet<array{...}>, so validate() and check()->validated() return the shape the rules enforce at runtime:
$data = RuleSet::from([ 'id' => FluentRule::integer()->required(), 'title' => FluentRule::string()->nullable(), ])->validate($payload); // array{id: float|int|numeric-string, title?: string|null}
validated() never casts, so each type is the set of raw inputs the chain lets through:
| Base | With required() |
Without required() |
|---|---|---|
string() |
non-empty-string |
string |
integer(), numeric() |
int|float|numeric-string |
int|float|string |
integer(strict: true) |
int |
int|string |
boolean() |
bool|0|1|'0'|'1' |
bool|0|1|string |
- A key without
required()is optional. It also admitsstring, because Laravel skips every non-implicit rule for a string that trims to empty, and''or' 'reachesvalidated()unchanged. nullable()addsnullonly withoutrequired(), which rejects null.- A conditional presence modifier (
sometimes(),requiredIf(),exclude*(),missing*(),prohibited*(),present*(),filled()) makes the key optional. rule()is read by rule name.rule('required')andrule('nullable')act like their methods, Laravel's presence rules (exclude*,sometimes,required_*,prohibited*,missing*,present*,filled, and objects such asRule::excludeIf()orRule::requiredIf()) make the key optional, and any other rule,Password::default()or a closure included, keeps the type.- Any other base (
enum(),date(),email()and so on) or an unlisted constraint method types the value asmixed, butrequired()still keeps the key present. when(),unless(),whenInput(),Rule::when(), a macro, arule()argument that is not one literal value, or a chain that is not a literalFluentRule::(orFluentSchema) call turns that key into an optionalmixed.- A dotted, wildcard or numeric key, a spread or a non-literal key leaves the whole return type at the declared
array<string, mixed>. - On an older
laravel-fluent-validation,RuleSetis not generic and the extension leavesfrom()untouched.
validated() on fluent FormRequests
$request->validated() on a final FormRequest that uses HasFluentRules resolves to the same shape RuleSet::from() gets, read from the literal array its schema() (or its own rules()) returns:
final class StoreVideoRequest extends FormRequest { use HasFluentRules; public function schema(FluentSchema $rules): array { return [ 'title' => $rules->string()->required(), 'duration' => FluentRule::integer(), ]; } } $request->validated(); // array{title: non-empty-string, duration?: float|int|string} $request->validated('title'); // non-empty-string $request->validated('duration', 0); // float|int|string
validated('key', $default)adds the default's type when the key is optional, and returns the default's type for a key the rules don't declare. A dotted key or a default that may be aClosurekeeps Laravel'smixed.- The body must be a single
return [...]orreturn RuleSet::from([...]). A spread,parent::rules(), a merge or any other statement keepsmixed. validated()keepsmixedwhen:- the class isn't
final, because a subclass could change its rules; - it declares both
schema()andrules(), which the trait merges; - it overrides
passedValidation(),validated(),validator(),createDefaultValidator()orgetValidatorInstance().
- the class isn't
prepareForValidation()only changes the input the rules then check, so it doesn't stop the inference.- A value that depends on
$thisreads as an optionalmixed. - PHPStan can't connect
$request->has('key')tovalidated(), so an optional key still reads asT|nullafter such a guard. Pass a default,validated('key', $fallback), or make the rulerequired()when the key is always sent.
Testing
composer test
Before opening a PR, run the full pipeline (Pint, Rector, PHPStan, tests):
composer qa
Changelog
See CHANGELOG.md for release notes.
Contributing
See CONTRIBUTING.md.
License
MIT. See LICENSE.md.