
phpstan-laravel-validation
Laravel validation is not a typed data boundary. This extension recovers
sound structural types for successful validated() output from statically
resolvable rule sets.
The inferred type may be broader than a rule name suggests because Laravel usually preserves the original native value. See Laravel Validation and Type Safety for the evidence.
Caution
For new type-conscious code, prefer a boundary with an explicit, normalized output contract. This library is a mitigation for existing Laravel validation, not an endorsement of that design.
Start here
$data = \Illuminate\Support\Facades\Validator::make($request->all(), [
'person' => 'required|array',
'person.*.email' => 'required|string|email|unique:users',
'person.*.first_name' => 'required|string',
'person.*.age' => 'required|integer|string',
])->validated();
\PHPStan\dumpType($data);
// array{person: array<int|string, array{
// email: non-empty-string,
// first_name: string,
// age: numeric-string
// }>}
- Install and run a first analysis. Getting Started
- Understand why a type is broader than the rule name. Understanding Inferred Types
- Opt into normalized scalar, date/time, timezone, or backed-enum output. Parsing Validated Output
- Look up a rule, builder, or configuration key. Configuration, Validation Rules, Rule Builders
The 0.1 line is an experimental public release. Compatibility and known limitations are in Getting Started and Limitations.
Getting Started
Install the extension as a development dependency, register it with PHPStan,
and analyse a validated() call. Full configuration is in
Configuration.
Installation
Requires PHP 8.1. Supported on PHP 8.1 through 8.5, PHPStan 2.1.5 or later, and Laravel 10 through 13.
composer require --dev jbboehr/phpstan-laravel-validation
If you also install
phpstan/extension-installer,
the extension is registered automatically.
Otherwise include it from phpstan.neon:
includes:
- vendor/jbboehr/phpstan-laravel-validation/extension.neon
First analysis
$validated = \Illuminate\Support\Facades\Validator::make($input, [
'name' => 'required|string',
'age' => 'integer',
])->validated();
\PHPStan\dumpType($validated);
// array{name: string, age?: float|int|string|Stringable|true}
name is required. age is optional, and its value type includes every
native representation Laravel can accept and preserve for integer.
The same rule-set inference applies to factory make() / validate(),
Request::validate(), and controller validate(). See
Supported Entry Points.
Common first options
Most projects can start with the defaults. These are the options most projects are likely to consider first:
| Option | Default | When to change it |
|---|---|---|
laravelVersion | auto | PHPStan’s working directory is not the Composer project that owns Laravel |
assumeHttpInputNormalization | false | Request validation always runs after Laravel’s default trim/empty-string middleware |
formRequests.enabled | false | You want experimental FormRequest::validated() inference |
parameters:
phpstanLaravelValidation:
laravelVersion: auto
Details, diagnostics, and the remaining options are in Configuration.
Compatibility
- PHP 8.1 through 8.5
- PHPStan 2.1.5 or later
- Laravel 10 through 13
Version-specific inference boundaries are listed in Laravel Version Behavior.
Next
- Understanding Inferred Types
- Parsing Validated Output
- FormRequest Inference
- Custom Validation Rules
- Limitations
Understanding Inferred Types
The extension describes what Laravel can return after successful validation. It does not describe the type a rule name appears to promise.
Soundness
Every successful Laravel output must be a subtype of the inferred type. When Laravel preserves several native representations, the inferred type is a union of those representations.
$validated = Validator::make($input, [
'age' => 'required|integer',
])->validated();
\PHPStan\dumpType($validated);
// array{age: float|int|numeric-string|Stringable|true}
integer accepts and preserves 1, 1.0, true, numeric strings, and
compatible Stringable objects. Narrowing that to int would be false.
See
Laravel Validation and Type Safety
and Validation Rules.
Add an adjacent native-family rule when you know the input representation:
$validated = Validator::make($input, [
'age' => 'required|integer|string',
])->validated();
\PHPStan\dumpType($validated);
// array{age: numeric-string}
Presence is separate from the value type
required makes a key required. It does not change the value family.
present requires the key and still allows a blank string to bypass
non-implicit adjacent rules. missing and exclude omit the path from
successful output.
Details are in Presence and Output Projection.
Nested arrays and wildcards
An explicit parent array rule makes that offset required. Wildcard children
alone do not: the parent may be absent, and when present it is still a
non-null array.
$validated = Validator::make($input, [
'person' => 'required|array',
'person.*.email' => 'required|string|email',
])->validated();
\PHPStan\dumpType($validated);
// array{person: array<int|string, array{email: non-empty-string}>}
Without 'person' => 'required|array', the parent is person?.
Bare array parents with nested children are rebuilt from those children.
Parameterized array:name,email and array_keys preserve the permitted
parent instead. See
Presence and Output Projection.
When inference stays conservative
Precise inference requires a complete, statically visible rule expression. Assigned builders, dynamic calls, callbacks, macros, and unknown custom predicates fall back rather than guessing. The common rule is in Static Resolvability.
Input refinement after validate()
A successful direct facade or Factory::validate() call can narrow safe
top-level fields on the caller’s original array. That is an input constraint,
not a claim that the array was replaced by validated() output.
/** @var array<string, mixed> $input */
Validator::validate($input, ['name' => 'required|string']);
\PHPStan\dumpType($input['name']); // string
Limits are listed under Supported Entry Points.
Parsing Validated Output
Laravel’s built-in validation rules normally test the original input and preserve its PHP representation. The experimental Rensei rules provide an explicit exception: a parsing rule either produces a value of its declared type or fails validation.
Warning
Rensei is experimental. Its runtime and inferred contracts may change before the feature is declared stable.
Installation and compatibility
Parsing rules execute inside the application, not only during PHPStan
analysis. If a deployed code path uses Parse::*, this package must remain
installed in production. Installing it only with composer require --dev and
then deploying with composer install --no-dev removes the runtime classes.
For runtime use in a Laravel application, install it without --dev:
composer require jbboehr/phpstan-laravel-validation
Analysis-only users can continue to use composer require --dev. The current
package also installs PHPStan and nikic/php-parser as production dependencies
when installed under require.
The analysis extension supports Laravel 10.0 through 13. The parsing runtime
requires Laravel 10.7 or newer because it uses Validator::setValue() for
checked final write-back. Composer cannot express a version floor that applies
only when an optional class is used, so an older Laravel release fails with
UnsupportedLaravelVersion when it attempts to run a parser. When PHPStan can
identify a supported laravel/framework version below 10.7, it also reports
each statically resolvable parser use as
laravelValidation.parsingRuleLaravelVersion. If the version is unavailable,
analysis stays conservative about compatibility and the runtime guard still
fails closed.
Escape literal dots in rule paths as Laravel requires: a\.b addresses the
key a.b, while a.b addresses b inside a. Both paths can have parsing
rules, but each must use a separate parser instance. Laravel passes the same
decoded name to both callbacks, so sharing one parser instance between them
fails validation. Sharing an instance across paths with distinct decoded names
remains supported. Escaped paths also work on older parsing-supported releases
that use unmarked internal placeholders.
No Rensei-specific PHPStan configuration flag is required. The extension reads
the produced type from the concrete parsing rule’s ParsingRule<T> binding.
Using the rules at runtime also makes this package a deployed dependency. It is licensed under AGPL-3.0-or-later, which should be considered before moving it from an analysis-only dependency.
Basic use
use Illuminate\Support\Facades\Validator;
use jbboehr\Rensei\Parse;
enum AccountStatus: string
{
case Pending = 'pending';
case Active = 'active';
}
$validator = Validator::make($input, [
'age' => ['required', Parse::integer()],
'ratio' => ['required', Parse::float()],
'identifier' => ['required', Parse::string()],
'payload' => ['required', Parse::base64()],
'enabled' => ['required', Parse::boolean()],
'terms' => ['required', Parse::accepted()],
'opt_out' => ['required', Parse::declined()],
'status' => ['required', Parse::enum(AccountStatus::class)],
'starts_at' => ['required', Parse::dateTime()],
'timezone' => ['required', Parse::timezone()],
]);
$validated = $validator->validated();
\PHPStan\dumpType($validated);
// array{age: int, ratio: float, identifier: string, payload: non-empty-string,
// enabled: bool, terms: true, opt_out: false, status: AccountStatus,
// starts_at: DateTimeImmutable, timezone: DateTimeZone}
$safe = $validator->safe()->all();
For corresponding input, both output calls contain 42, 1.5, a string
identifier, decoded bytes, false, literal true and false acceptance
values, AccountStatus::Active, a DateTimeImmutable, and a DateTimeZone.
PHPStan deliberately retains Laravel’s broad array type for
$validator->safe()->all(). A factory may use Factory::resolver() to return
a custom Validator whose virtual validated() method changes the payload, so
the wrapper cannot receive the ordinary validator’s structural contract
soundly. Supported safe() projections on conventional FormRequests can carry
the parsed shape when FormRequest inference is enabled and all discovered
concrete request contracts can be resolved.
The caller’s $input array is not rewritten. A request also retains its
original values through $request->all() and $request->input(). Parsing
changes the successful validator output, not the incoming request.
Exact parser grammars
Parse::integer()
Accepts any native int and canonical decimal strings matching
^-?(0|[1-9][0-9]*)\z within the platform integer range. The \z anchor is
intentional: unlike $, it does not match before a final newline. It produces
int.
It rejects floats, booleans, leading +, leading zeroes, whitespace,
decimals, scientific notation, trailing data, and integer overflow. For
example, '42' becomes 42 and '-0' becomes 0, while '042', '+42',
'42.0', and 42.0 fail validation.
Parse::float()
Accepts finite native floats, widens native ints, and accepts canonical ASCII
decimal strings matching ^-?(0|[1-9][0-9]*)(\.[0-9]+)?\z. It produces
float.
It rejects booleans, leading +, leading zeroes, whitespace, scientific
notation, locale-specific separators, INF, NAN, and decimal strings whose
conversion overflows to infinity. For example, '42' becomes 42.0 and
'-1.5' becomes -1.5, while '01.5', '.5', '1e3', and INF fail.
Conversion to PHP’s native float may lose precision or underflow to zero; the
parser promises a finite float, not arbitrary-precision decimal arithmetic.
'-0' and '-0.0' preserve IEEE 754 negative zero.
Parse::string()
Accepts native strings unchanged, converts native integers to their lossless
decimal representation, and converts Stringable objects through
__toString(). It produces string. A Stringable conversion that throws or
otherwise fails becomes an ordinary validation failure.
It rejects floats, booleans, arrays, resources, and non-Stringable objects.
Generic float stringification would introduce a mutable precision and spelling
policy; PHP’s boolean casts would turn true into '1' and false into ''.
Those conversions are not part of this grammar.
This parser is not Laravel’s string predicate under another name:
'string' // accepts native strings and preserves them
Parse::string() // deliberately converts int and Stringable input
Adjacent Laravel rules still inspect the original representation. For example,
['integer', Parse::string()] accepts native integers and strings that satisfy
Laravel’s non-strict integer predicate, then returns a string. It does not
impose the canonical grammar used by Parse::integer(): strings such as
'+42', ' 42', and '42 ' are preserved unchanged. The string parser still
rejects an integral float or true even though Laravel’s integer predicate
accepts them. A Stringable object may produce an empty string after
required has accepted the original object; required does not constrain the
later parsed representation.
Parse::base64()
Accepts a non-empty native string in canonical standard Base64 and produces
the represented bytes as non-empty-string. The output is a PHP binary string;
it is not promised to be UTF-8 text. For example, 'aGk=' becomes 'hi', and
'AA==' becomes a one-byte string containing NUL.
The parser uses strict decoding and requires that encoding the decoded bytes
reproduce the input exactly. It therefore rejects whitespace, the URL-safe
alphabet, extra padding, and spellings such as 'aGk' that omit required =
padding. Canonical input such as 'TWFu' needs no padding and remains valid.
Empty input fails rather than producing an empty string.
Laravel added its preserving base64 predicate in 13.21. Parse::base64()
performs its own validation and is available on every Laravel release supported
by the parsing runtime. The native base64 rule is not required, and does not
exist as a built-in on earlier releases.
Parse::boolean()
Accepts exactly Laravel’s strict boolean input set and produces bool:
true, 1, '1' -> true
false, 0, '0' -> false
Values such as 'true', 'false', 'on', 'off', floats, and blank
strings fail. PHP truthiness is deliberately not used.
Parse::accepted()
Accepts exactly Laravel’s accepted token set and produces literal true:
'yes', 'on', '1', 1, true, 'true' -> true
Parse::declined()
Accepts exactly Laravel’s declined token set and produces literal false:
'no', 'off', '0', 0, false, 'false' -> false
These are separate parsers rather than a widened Parse::boolean() contract.
They are also unconditional grammars: Laravel’s accepted_if and
declined_if predicates may become inactive and leave an arbitrary original
value untouched, so they do not imply a corresponding conditional parser.
Parse::enum()
Accepts an existing case of the configured backed enum or a value with exactly the enum’s native backing type. It produces the concrete enum case.
String-backed enums accept only strings; int-backed enums accept only ints.
Unlike Laravel’s Rule::enum(), an int-backed enum does not accept the string
'1', and a string-backed enum does not accept the integer 1 through PHP
coercion.
Passing a pure enum throws InvalidArgumentException when the rule is
constructed because it has no canonical wire representation. Name matching
and only() / except() filters are not supported.
Parse::dateTime()
Produces DateTimeImmutable from Laravel-compatible date input by default, or
from one or more exact PHP date formats:
Parse::dateTime()
Parse::dateTime(timezone: 'America/New_York')
Parse::dateTime('Y-m-d H:i:s')
Parse::dateTime(['Y-m-d', DateTimeInterface::ATOM])
Parse::dateTime('Y-m-d H:i:sP', 'America/New_York')
Parse::dateTime('Y-m-d', new DateTimeZone('UTC'))
With no format, acceptance follows Laravel’s ordinary date rule: PHP’s
strtotime() must recognize the string or numeric value, date_parse() must
describe a valid calendar date, and this parser must be able to construct a
DateTimeImmutable from the same input. This deliberately includes Laravel’s
broad grammar. For example, 2024-2-29, the integer 20240229, and
2024-02-29 +1 day pass; tomorrow alone and 2024-02-30 fail. Laravel
validates and preserves those successful scalar representations; this parser
turns them into an immutable date object. Embedded NUL bytes are rejected even
on PHP versions where Laravel’s predicate accepts them: a parser must not let a
hidden byte change the apparent date into a different date or timezone.
An explicit format selects strict mode. String input is parsed with
DateTimeImmutable::createFromFormat(), with unspecified fields reset from
the Unix epoch. Parsing must report no warnings or errors, and formatting the
result with the same format must reproduce the input byte for byte. This
rejects PHP’s ordinary date normalization, relative strtotime() expressions,
trailing data, whitespace, and alternate spellings. For example, 2024-02-29
matches Y-m-d; 2024-02-30, 2024-2-29, and tomorrow do not. A non-empty
list tries exact formats in declaration order and returns the first match.
The format is used both to parse and to reproduce the input. Unescaped
createFromFormat()-only controls (!, |, +, *, ?, and #) are
therefore rejected when the rule is constructed; ! reset semantics are
already applied automatically. Escape one of those characters with \ only
when it is intended as literal input. A dangling escape is also invalid.
UTC is the default output timezone for input that does not carry one. An
explicit DateTimeZone or identifier string changes that fallback; an offset
or timezone parsed from the input takes precedence. The configured timezone,
not PHP’s process default, is used when constructing zone-less output in both
modes. Default mode still uses strtotime() as Laravel’s compatibility gate.
An @ timestamp fixes an instant but PHP otherwise forces its result to
+00:00; this parser represents that instant in the configured timezone.
In strict mode, Unix-timestamp formats such as U and U.u retain the encoded
instant but represent the resulting object in the configured timezone. Because
a Unix timestamp already fixes the instant, combining U with an input
timezone character such as e, O, P, p, or T is rejected at
construction. A nonexistent local wall time during a daylight-saving
transition fails because PHP’s normalized result does not round-trip exactly.
Ambiguous local wall times follow the timezone database’s resolution; include
an offset in the format when the instant itself must be unambiguous.
An existing DateTimeImmutable passes through unchanged. Another
DateTimeInterface implementation is copied with
DateTimeImmutable::createFromInterface(), preserving its instant and
timezone. The configured formats and timezone constrain scalar input only. An
invalid format, format list, or timezone is rejected when the rule is
constructed.
Parse::timezone()
For required input, this produces DateTimeZone from the same string
identifiers accepted by Laravel’s default timezone rule. Matching is exact
and case-sensitive. An existing DateTimeZone passes through unchanged,
including one constructed from a representation outside Laravel’s string
grammar.
The parser deliberately does not accept every string understood by the
DateTimeZone constructor. Numeric offsets such as +05:30, abbreviations
such as EST, backward-compatible aliases such as US/Eastern, and the
Etc/* group—including Etc/UTC and Etc/GMT+5—are constructor inputs but
are not members of Laravel’s default
timezone_identifiers_list(DateTimeZone::ALL) set. Accepting them would make
the parser’s apparent relationship to Laravel’s rule false.
This parser does not expose Laravel’s group and country parameters. From Laravel 10.12 onward, an application that needs a subset can keep that predicate as an ordinary rule so it checks the original identifier before parsing:
'timezone' => ['required', 'timezone:per_country,US', Parse::timezone()]
Laravel 10.7 through 10.11 silently ignores those parameters and applies the default identifier set. On those releases, use an explicit membership rule:
use Illuminate\Validation\Rule;
$usTimezones = timezone_identifiers_list(DateTimeZone::PER_COUNTRY, 'US');
'timezone' => ['required', Rule::in($usTimezones), Parse::timezone()]
Unlike Laravel’s optional non-implicit timezone rule, the parser is implicit:
a present blank or whitespace-only value is parsed and rejected rather than
being skipped and preserved.
Presence and adjacent Laravel rules
A parser controls the value it produces, not whether the key must exist:
['age' => [Parse::integer()]] // array{age?: int}
['age' => ['required', Parse::integer()]] // array{age: int}
['age' => ['nullable', Parse::integer()]] // array{age?: int|null}
That separation also applies to accepted and declined tokens. Laravel’s
built-in accepted and declined rules imply requiredness; their parsing
counterparts do not. Use ['required', Parse::accepted()] or
['required', Parse::declined()] to preserve that presence behavior while
normalizing the successful output.
Parsing rules are implicit, so a present blank string cannot bypass them. The
value is passed to the parser and fails unless that parser’s grammar accepts
it. In particular, a string-backed enum may legitimately declare '' as a
backing value. nullable preserves a present null; it does not make other
blank values nullable.
Ordinary Laravel rules always observe the original representation. Write-back happens only after they finish. This matters for Laravel’s size rules:
'age' => ['required', 'integer', Parse::integer(), 'min:18']
The integer predicate tells Laravel to compare min numerically. Without a
named numeric rule, Laravel may compare the raw string by length even though
validated() later contains an int. Rule order does not change that phase
boundary.
PHPStan reports this combination as
laravelValidation.parsingNumericSize when a numeric parser is paired with
min, max, between, or size but the rule list has no integer,
numeric, or decimal marker. For an integer-producing parser, add
integer, numeric, or an appropriate decimal rule. For a float-producing
parser, use numeric or an appropriate decimal rule; integer rejects
non-integral values. If a custom numeric parser intentionally accepts values
whose original string, array, or file representation should be measured, the
diagnostic can be ignored by identifier at that site.
Which values each phase observes
| Location | Representation |
|---|---|
| Ordinary Laravel rules | Original input |
An after() callback registered before validation | Original input |
Successful validated() and safe() output | Parsed values |
| The caller’s array or request input | Original input |
FormRequest passedValidation() at runtime | Parsed values |
Do not read validated(), safe(), valid(), or getData() from an
after() callback. Normal callbacks run before parser write-back and therefore
observe the pre-parse state. PHPStan does not currently model this callback
phase and may still expose the final parsed type there.
FormRequests
Parsing rules can be returned from FormRequest::rules() like any other rule.
After successful validation, validated() and safe() contain parsed values,
while all() and input() retain the request values.
Enable FormRequest inference if PHPStan should infer the
parsed shape or union of discovered concrete contracts for conventional
FormRequests, including supported direct safe() projections. If application
code must consume the parsed values
during the FormRequest lifecycle, passedValidation() is the runtime
post-write-back hook. Declaring that hook currently makes the extension
conservatively decline FormRequest inference for the class because the hook
can mutate application state or replace the effective contract.
Lifecycle and soundness limits
- A validator that completes parser write-back is single-use.
passes(),fails(), andvalidate()each start a validation run and must not be called again on that validator. After one run,validated(),safe(),valid(),invalid(), anderrors()reuse its result. Usefails()followed byvalidated(), notfails()followed byvalidate(); repeatedvalidate()also fails. A first call tovalidated()may perform the one allowed run and preserves the inferred shape, whereas directValidator::validate()currently retains Laravel’s broadarrayreturn type. - Parsing rules cannot be serialized or unserialized. Their validator-scoped lifecycle state is not transferable, and accepting deserialized properties could bypass the immutable implicit marker on which parsed types depend.
valid()on failed or short-circuited validation is not parsed output. It may contain raw attributes whose parsing rules Laravel never reached.- Executable custom rules and runtime validation extensions can mutate data
outside the parser’s finalization contract. When they are combined with a
parser and no usable static lifecycle contract exists, PHPStan returns
mixedrather than promising the parser’s produced type. - Mutating an inferred validator through
setData(),setValue(),setRules(),addRules(), or imperativesometimes()is subject to the diagnostics and widening described under Validator mutation.
Applications may define another parsing rule through the runtime API:
use jbboehr\Rensei\ParseFailure;
use jbboehr\Rensei\Rules\BaseParsingRule;
/** @extends BaseParsingRule<non-empty-string> */
final class NonEmptyStringRule extends BaseParsingRule
{
public function parse(mixed $value): string
{
if (!is_string($value) || $value === '') {
throw new ParseFailure();
}
return $value;
}
protected function message(): string
{
return 'The :attribute field must not be empty.';
}
}
parse() returns the declared T or throws ParseFailure; message() is
required for validation failures. Direct static inference deliberately
requires a final concrete BaseParsingRule<T> subclass. A non-final parser can
be extended with an implicit property that shadows the base class’s immutable
marker, so PHPStan conservatively declines it. A final parser must not declare
that property itself.
Application abstractions may intentionally expose only the generic parser contract. Adapt that value before putting it in a Laravel rule list:
use jbboehr\Rensei\Parse;
use jbboehr\Rensei\ValueParser;
/** @return ValueParser<int> */
function ageParser(): ValueParser
{
return Parse::integer();
}
$rules = [
'age' => ['required', Parse::using(ageParser())],
];
ValueParser<T> is a pure conversion contract; it does not require application
code to implement Laravel’s validation callbacks. Parse::using() delegates
only parse() to the supplied parser. Its final
concrete adapter supplies the immutable implicit marker, single-use validator
lifecycle, and delayed write-back that PHPStan cannot prove from the interface
alone. The adapter retains T, so the example still infers age as int.
Using an abstract Laravel-facing ParsingRule<T> directly remains
conservative.
Laravel validation and type safety
Caution
Laravel validation is not a typed data boundary. Its compact rule syntax combines value predicates with presence rules, cross-field control flow, wildcard traversal, and output projection. Successful validation often preserves a native value that the rule name appears to exclude or normalize.
phpstan-laravel-validationdescribes that runtime contract as honestly as possible. It can mitigate the problem for existing applications; it cannot turn the underlying design into a coherent typed transformation.
TL;DR
Laravel validation mostly establishes that an input value satisfies some
predicates. It generally does not construct a correspondingly typed
representation of that value. A rule such as integer therefore cannot
soundly mean that validated() returns an int.
That is the central problem, but not the only one. The same rule array also projects the output by including, excluding, and rebuilding keys, while conditional rules, wildcards, callbacks, and runtime services make it behave more like a program than a static schema.
Laravel validation is not typed parsing
A typed parser consumes one representation and produces a value whose native
type is part of its contract. Laravel validation usually answers a different
question: does this original value happen to satisfy these rules? When the
answer is yes, validated() generally returns that original value.
The integer rule is the canonical example. At the pinned Laravel 10
through 13 releases, both of these validations succeed:
Validator::make(
['value' => 1.0],
['value' => 'required|integer'],
)->validated();
// ['value' => 1.0]
Validator::make(
['value' => true],
['value' => 'required|integer'],
)->validated();
// ['value' => true]
A Stringable object returning an accepted integer string also passes and is
returned as the same object. The rule has not produced an int. It has
accepted values that PHP’s filter semantics consider integer-like and preserved
their original native types. The sound inferred value type is therefore:
float|int|numeric-string|Stringable|true
This union is necessarily broader than Laravel’s successful subset because
PHPStan cannot express “an integral float” or “an object whose string
representation passes this PHP filter.” Narrowing it to int would be more
attractive and false.
Laravel 12.22 added one revealing exception: integer:strict begins
requiring a native integer. Laravel 10, Laravel 11, and Laravel 12.0 through
12.21 accept the same spelling but ignore the strict parameter. The rule’s
meaning therefore depends on the installed framework release as well as its
text.
Digit-count rules have the same problem, with different release boundaries.
For example, required|digits_between:1,3 accepts and preserves true and a
Stringable object rendering '1' on Laravel 10, 11, 12, and 13.0 through
13.5. Laravel 13.6 rejects those native types. The corresponding guards arrived
earlier for digits in 12.35 and for min_digits and max_digits in 13.4.
Before those guards, zero-length bounds can even admit false or null.
The version audit
records the boundary probes and the broader inferred families needed to
describe these preserved values.
The scalar in rule provides a particularly sharp second example. At every
pinned Laravel revision, its relevant implementation is:
return ! is_array($value) && in_array((string) $value, $parameters);
For required|in:1, Laravel accepts and preserves '1', 1,
1.0, true, numeric-equivalent strings such as '01', and a
compatible Stringable object. The cast is used for comparison and then
discarded. The native integer branch can be narrowed to literal 1, but the
sound inferred type remains surprising:
1|float|numeric-string|Stringable|true
The float branch cannot be narrowed to 1.0: PHP’s configurable float
formatting allows nearby floats to stringify as '1'. PHPStan also has no
type for the numeric-string equivalence class admitted by Laravel’s loose
comparison. in:1 is therefore not an enum-like declaration of a literal
output value. This is not an analyzer inventing an inconvenient edge case. It
is Laravel preserving values admitted by the runtime contract Laravel
created.
The more faithfully static analysis models this behavior, the less the rule resembles the narrow declaration it appears to be.
The current_password rule delegates acceptance to the configured hasher
without first requiring a native string. With Laravel’s default bcrypt hasher,
an authenticated fixture user whose stored hash represents '12345' can
validate this input:
$factory->make(
['value' => 12345],
['value' => 'required|current_password'],
)->validated();
// ['value' => 12345], still an integer.
The runtime tests confirm native-value preservation on Laravel 10 through 13.
Because applications can replace the hasher, its accepted native types cannot
be inferred from the rule name alone. The extension therefore leaves this
rule as mixed. Add string when a native string is required; that companion
rule rejects the integer above and supplies the corresponding inferred type.
Laravel validation can still enforce useful runtime domain constraints such as email syntax, ranges, and membership. The problem is not that predicates are useless. The problem is mistaking successful predicates for a declaration of the returned native representation.
Optionality changes the accepted value domain
Laravel overloads optionality with blank-value behavior. Many non-implicit
rules are skipped when an optional field is a blank string, but the present
field can still be returned by validated():
$validated = Validator::make(
['filters' => ''],
['filters' => 'array'],
)->validated();
// ['filters' => '']
\PHPStan\dumpType($validated);
// array{filters?: array|string}
The field may be absent, present with an array, or present with a blank string
for which the array predicate never ran. Whitespace-only strings have the
same validator-level behavior. Adding required changes the accepted value
domain as well as key presence.
Laravel’s standard HTTP middleware commonly trims strings and converts empty
strings to null, so ordinary request flows may not expose this exact branch.
Direct validators, jobs, tests, programmatically assembled data, and customized
middleware stacks still do. Trimming alone is insufficient: it produces the
empty string that bypasses the rule.
The extension offers an explicit HTTP-normalization assumption for request and controller inference. It is an application assertion, not automatic middleware detection; skip callbacks, trim exceptions, and request mutation can invalidate it. Laravel’s default password-related trim exceptions also differ between Laravel 10 and later supported majors, so even this preprocessing assumption is version-sensitive.
Validation is also projection
Laravel’s rule array does not merely decide whether input is acceptable. It also decides which successful values appear in the result and how nested output is reconstructed.
Exclusion rules remove accepted input
An exclusion rule can remove a present value from successful output:
$rules = [
'kind' => 'required|string',
'value' => 'required|string|exclude_if:kind,guest',
];
Validator::make([
'kind' => 'guest',
'value' => 'secret',
], $rules)->validated();
// ['kind' => 'guest']
Validator::make([
'kind' => 'member',
'value' => 'visible',
], $rules)->validated();
// ['kind' => 'member', 'value' => 'visible']
The value is not merely conditionally valid. It is conditionally absent from
the output. Without preserving the relationship to kind, the honest
structural summary gives value an optional offset.
Nested rules decide which keys survive
A bare array rule validates the parent and preserves every nested key:
$input = [
'user' => [
'name' => 'Ada',
'admin' => true,
'metadata' => ['source' => 'import'],
],
];
Validator::make($input, [
'user' => 'required|array',
])->validated();
// The complete user array is preserved.
Laravel provides several different mechanisms that are easy to conflate:
array:namerejects an input array containing keys other thanname.- With the validator factory’s default exclusion setting, adding
user.name => required|stringbelow a barearrayparent rebuilds the parent from validated children and omits unmentioned siblings. - Calling
includeUnvalidatedArrayKeys()on the factory disables that reconstruction and preserves unmentioned siblings again. - A parameterized parent such as
array:nameis not Laravel’s literal reconstruction marker. It preserves the complete permitted parent around nested rules, even when those rules emit nothing. - Laravel 11.23 makes a literal
listanother reconstruction marker. A required first wildcard projection can preserve listness. An earlier optional path can instead emit later numeric keys first, while a missing or excluded path can remove elements; the returned array may therefore be sparse or merely ordered as1, 0rather than0, 1.
The key list restricts acceptable input keys. Nested rules can project selected
children into the output, but whether that projection replaces the parent also
depends on the exact parent-rule spelling. A bare array rule without child
rules preserves undeclared nested keys, so inferring a closed nested shape from
it would be unsound. Whether a key survives depends on parent rules, child
rules, and validator-factory configuration—not simply on a predicate attached
to that key.
Rules are runtime programs, not static schemas
A rule attached to one field cannot necessarily be interpreted from that field alone. Paths may traverse runtime collections, other fields may activate or deactivate constraints, and callbacks or services may supply behavior that is not present in the rule expression.
Cross-field rules require correlated types
accepted_if changes its accepted values according to another field:
$rules = [
'other' => 'required|string',
'value' => 'required|accepted_if:other,match',
];
Validator::make([
'other' => 'different',
'value' => 42,
], $rules)->validated();
// ['other' => 'different', 'value' => 42]
Validator::make([
'other' => 'match',
'value' => 'yes',
], $rules)->validated();
// ['other' => 'match', 'value' => 'yes']
Reading the rule as an unconditional local restriction excludes the valid
42 branch. A precise model must correlate other with the value
domain of value. required_if, required_with,
exclude_if, exclude_unless, and related rules introduce similar
relationships between values, presence, and output shape.
Such conditions can be represented as unions of correlated shapes in principle. Interacting conditions, blank states, wildcards, and exclusions multiply branches quickly; callback conditions may provide no static contract at all. The apparently local declaration is a runtime program over the rest of the input.
When another field’s inferred literal domain makes one condition inevitable,
no correlated union is needed. This extension has a default-off experimental
mode for those direct present_if, present_unless, missing_if, and
missing_unless cases. If both outcomes remain possible, inference retains
the conservative optional shape rather than inventing a correlation PHPStan
will not preserve. The conditional present rules are refined only when the
detected Laravel version is 10.32 or later; older and unknown versions retain
the conservative result.
Wildcards are quantified traversal
A required wildcard descendant does not require any match to exist:
$validated = Validator::make([], [
'person.*.email' => 'required|string|email',
])->validated();
// []
\PHPStan\dumpType($validated);
// array{person?: array<int|string, array{email: non-empty-string}>}
required applies to each element discovered by wildcard expansion. If
there are no elements, there are no failed checks and no person key in
the result. The descendant is required while its containing collection remains
optional.
This path is not a shape declaration. It combines traversal, quantification over runtime elements, validation of each match, and construction of matching output paths.
The language is open-ended at runtime
Rules can be assembled dynamically or obtained from arbitrary services:
$rules = app(TenantValidationRules::class)->forRequest($request);
$validated = Validator::make($request->all(), $rules)->validated();
A declared return type, source analysis, or project-specific PHPStan extension
may recover a contract. Larastan can go further by
booting the Laravel application
and sometimes
resolving services through the container.
That is analysis-time execution of application infrastructure, not a contract
expressed at the call site, and its result depends on the available bootstrap
and application state. phpstan-laravel-validation does not currently use
that strategy.
Custom rule objects, closures, and registered validator extensions add runtime
semantics absent from Laravel’s built-in language. This extension preserves
conservative inference for unknown custom predicates and lets projects provide
a trusted accepted-value contract through configuration, a
ValidationRuleType attribute, or an
@laravel-validation-type PHPDoc tag. Registered string rule names require
configuration because the extension does not boot the application to discover
them.
Those declarations describe original values preserved after a custom predicate succeeds. They do not infer arbitrary mutation, implicitness, or output projection, and an incorrect declaration is unsound just like incorrect PHPDoc. Widening is not a tooling failure when analysis has no usable contract for runtime behavior. Silently inventing one would be.
One string language combines unrelated responsibilities
A Laravel rule array encodes all of the following:
- key presence and blank-value policy;
- value predicates and coercive comparisons;
- cross-field dependencies;
- wildcard traversal;
- output inclusion, exclusion, and nested reconstruction;
- database-backed checks such as
existsandunique; and - application-defined callbacks, objects, and registered extensions.
These are not one clean operation. Their interactions determine validation success, native value types, key presence, and output shape. Laravel validation is difficult to type soundly because its rule language describes several loosely coupled runtime operations rather than one coherent data transformation.
Soundness versus precision
Here, soundness means that a static type includes every value Laravel can
return after successful validation. If Laravel can preserve true but the
type reports only string, the type is unsound.
Precision describes how much useful information remains. mixed may be
sound but nearly useless; a union can be sound and substantially more
informative. Neither property requires a class. A sound inferred array shape
provides genuine static type safety.
| Situation | Honest structural description | Distinct cause |
|---|---|---|
required with integer | float, int, numeric-string, Stringable, or true | Laravel preserves several native representations admitted by the predicate. |
optional array | optional array or string | A blank string can bypass the predicate. |
| conditional acceptance | a broad value when the branch is unknown | An inactive branch accepts values excluded by the active branch. |
| conditional exclusion | an optional output offset | A present input can be removed from the result. |
| wildcard-only descendants | an optional parent offset | Wildcard expansion can find no elements. |
bare array | a general array value | Unspecified nested keys are retained. |
| unknown custom predicate | mixed, intersected with adjacent known predicates | Runtime behavior has no usable static contract. |
Some breadth is required by Laravel’s runtime behavior; some reflects static information that is unavailable or not yet supported by the analyzer. That distinction matters. It prevents incomplete tooling from being excused as a framework limitation, while preventing tooling from disguising Laravel’s behavior with a narrower false type.
A broad inferred type is sometimes the only honest description of successful Laravel output.
What static analysis can salvage
Laravel’s validation APIs normally expose successful output as a general
array. For supported, statically resolvable rule expressions,
phpstan-laravel-validation can recover a useful structural type:
$validated = Validator::make($input, [
'email' => 'required|string|email',
'amount' => 'required|numeric|string',
])->validated();
\PHPStan\dumpType($validated);
// array{email: non-empty-string, amount: numeric-string}
For combinations covered by the implementation and conformance tests, the
extension can infer nested shapes, optional offsets, preserved-value unions,
and verified Laravel-version boundaries. It tracks supported validator unions,
applies declared custom-rule contracts, and its optional experimental
FormRequest inference can recover the
whole-payload validated() and validated(null) shapes of conventional
FormRequest subclasses from statically resolvable rules() returns. It
retains mixed where a field has no usable value contract. When the rule
expression itself cannot be resolved, PHPStan generally keeps Laravel’s broad
declared return type.
Form requests make the runtime-program problem concrete. A child can override
its parent’s rules, or replace validation through a lifecycle hook even when
rules() is final. A parameter typed as that parent can therefore receive a
different validated shape. The extension includes discovered concrete
descendant contracts in the receiver’s inferred type, retaining a union when
they differ. This assumes discovery covers the application’s request
implementations; unknown runtime subclasses remain outside that model.
Lifecycle hooks can also replace the validator. The extension declines
rules() inference for unsafe possibilities unless each affected concrete
class is explicitly trusted. Trust in a parent does not cover its children.
setValidator() after resolution remains outside that assumption. See
FormRequest Inference.
The extension does not make Laravel normalize values or invent contracts for arbitrary runtime code. It describes supported combinations covered by conformance tests. Finite tests do not prove universal soundness. An ugly union is often evidence of ugly framework behavior rather than analyzer failure.
Architectural alternatives for new code
Structural array-shape inference is genuine static type safety. The objection
to Laravel validation is its irregular runtime contract, not the fact that
validated() returns an array.
Applications may prefer DTOs, schema objects, explicit parsers, or a typed
object mapper such as
cuyz/valinor when they also want
normalization, nominal identity, runtime-enforced properties, or a named
architectural boundary. Those are separate advantages, not prerequisites for
useful array-shape inference.
Laravel validation can still perform runtime domain checks while static analysis describes its result. For new type-conscious code, a boundary whose output contract is explicit and normalized is usually easier to understand than reconstructing a type from Laravel’s interacting rule semantics.
Verification methodology
The concrete behaviors in this document are tested against Laravel itself rather than inferred from rule names. The repository supports Laravel 10 through 13, and its generated upstream-export fixtures are pinned to:
| Laravel | Release | Commit |
|---|---|---|
| 10 | 10.50.2 | 3ff39b7a9b83 |
| 11 | 11.55.0 | dc7ec34ae95b |
| 12 | 12.64.0 | 727a8ea2949c |
| 13 | 13.23.0 | 92a707229148 |
The exhaustive Nix matrix runs every supported major’s complete PHPUnit suite and independently checks the first release and known semantic boundaries through pinned runtime-audit jobs. The separate Laravel-version inference audit records boundary profiles, runtime snapshots, and audit limitations.
Runtime methods in the table below are defined in
tests/LaravelInferenceTest.php and
tests/CustomRulesLaravelRuntimeTest.php.
Conditional presence behavior is covered by
tests/ConditionalPresenceLaravelRuntimeTest.php.
FormRequest lifecycle behavior is covered by
tests/FormRequestLaravelRuntimeTest.php.
Hasher delegation and native-value preservation are covered by
tests/CurrentPasswordLaravelRuntimeTest.php.
| Claim | Laravel runtime coverage | PHPStan inference coverage |
|---|---|---|
integer can preserve non-integers | LaravelInferenceTest::testIntegerRuleCanPreserveNonIntegerValues | tests/rules/integer.php |
integer:strict differs by Laravel release | LaravelInferenceTest::testIntegerStrictRuleFollowsRuntimeSupport and testIntegerStrictRuleAcceptsAndPreservesNativeInteger | Boundary coverage in tests/TypeResolverTest.php, tests/version-aware/inference.php, and the version-audit snapshots |
| Digit-count predicates can preserve booleans, null, and objects before their native-type guards | LaravelInferenceTest::testDigitCountInferenceContainsNativeLaravelOutput and the version-audit snapshots on both sides of each boundary | TypeResolverTest::testVersionAwareDigitCountInference, tests/rules/digits.php, and tests/version-aware/inference.php |
current_password delegates to the configured hasher and preserves accepted native values | CurrentPasswordLaravelRuntimeTest::testCurrentPasswordPreservesAcceptedNativeValues, testCurrentPasswordUsesTheConfiguredHasherWithoutANativeTypeGuard, and testCurrentPasswordRetainsExplicitStringAndPresenceConstraints | Runtime-to-inference containment in those value-preservation tests, tests/rules/current-password.php, and TypeResolverTest::testResolvesSupportedRuleExactly |
base64 exists only from Laravel 13.21 and requires a native non-empty string | LaravelInferenceTest::testBase64RuleFollowsRuntimeVersionBoundary | Boundary coverage in tests/TypeResolverTest.php and tests/version-aware/base64.php |
Scalar in preserves coercible inputs and admits parameter-dependent integer equivalence classes | LaravelInferenceTest::testScalarInRuleAcceptsRuntimeValues, testNumericInRuleNarrowsOnlyItsRepresentableNativeIntegerClass, and testLargeFloatingPointInParameterAcceptsMultipleNativeIntegers | tests/rules/in.php and TypeResolverTest::testNumericInParametersNarrowOnlyRepresentableIntegerClasses |
| Optional blanks bypass non-implicit rules | LaravelInferenceTest::testBlankStringBypassesOptionalNonImplicitRules | tests/structure/empty-string.php |
| HTTP normalization changes blank behavior | LaravelInferenceTest::testDefaultHttpInputNormalizationChangesOptionalBlankBehavior, testTrimStringsAloneDoesNotEliminateBlankStringBypass, and testDefaultPasswordTrimExceptionVariesByLaravelMajor | tests/normalized/request.php, tests/structure/request.php, and tests/version-aware/inference.php |
| Conditional acceptance broadens values | LaravelInferenceTest::testConditionalValueRulesRemainConservative | tests/rules/accepted-if.php |
| Conditional exclusion changes shape | LaravelInferenceTest::testConditionalExclusionChangesTheValidatedShape | tests/rules/exclude-if.php |
| Definite conditional presence and absence can refine shape experimentally | ConditionalPresenceLaravelRuntimeTest::testConditionalPresenceMatchesExperimentalInference, testActiveConditionalPresenceRulesRejectTheOppositeShape, testConditionalPresentRulesFollowRuntimeAndInferenceBoundary, and the exact-profile conditional-presence-rule-audit.php probe | tests/conditional-presence/inference.php, tests/conditional-presence/before-introduction.php, and TypeResolverTest::testExperimentalConditionalPresenceInferenceResolvesDefiniteConditions |
| Required wildcard descendants may match nothing | LaravelInferenceTest::testRequiredWildcardDescendantDoesNotRequireMissingParent | tests/structure/wildcard.php |
| Bare arrays preserve nested keys | LaravelInferenceTest::testArrayRuleWithoutKeyParametersPreservesNestedKeys | tests/rules/array.php |
| Array key lists reject undeclared keys | LaravelInferenceTest::testArrayRuleKeyParametersRejectUndeclaredNestedKeys | tests/rules/array.php |
| Nested child rules project validated keys | LaravelInferenceTest::testParentAndChildRulesAcceptRuntimeOutput | tests/structure/parent-rules.php |
| Factory configuration changes nested projection | LaravelInferenceTest::testFactoryUnvalidatedArrayKeyModesMatchInference | tests/include-unvalidated-array-keys/inference.php |
| Parameterized arrays preserve the permitted parent around nested rules | PresenceLaravelRuntimeTest::testRuntimeProjection (named parameterized-parent cases) and the version-audit snapshots | tests/rules/missing.php and TypeResolverTest::testParameterizedArrayParentIsPreservedAroundNestedRules |
Literal list joins nested reconstruction in Laravel 11.23; projection order can preserve, sparsify, or reorder its keys | LaravelInferenceTest::testListRuleFollowsRuntimeVersionBoundary and testFactoryUnvalidatedArrayKeyModesMatchInference on the 11.22 and 11.23 profiles | tests/version-aware/list.php, tests/version-aware/list-projection.php, and TypeResolverTest::testListParentProjectionChangesInLaravel1123 |
| Custom predicates preserve successful original values | CustomRulesLaravelRuntimeTest::testObjectRulesPreserveSuccessfulValuesAndRejectOthers, testClosureRulePreservesSuccessfulOriginalValue, and testRegisteredStringRulePreservesSuccessfulOriginalValue | tests/custom-rules/inference.php |
| FormRequest lifecycle hooks can change effective rules and later output | FormRequestLaravelRuntimeTest::testWithValidatorCanReplaceTheEffectiveRules, testIntermediateWithValidatorHookCanReplaceTheEffectiveRules, testTraitWithValidatorHookCanReplaceTheEffectiveRules, testPassedValidationCanReplaceRulesAfterSuccessfulValidation, and testCustomValidatorCanIgnoreRulesMethod | tests/form-request/inference.php |
| A parent-typed FormRequest can receive child output that violates the parent’s rules contract | FormRequestLaravelRuntimeTest::testPolymorphicChildCanReplaceTheParentsRulesContract, testFinalRulesMethodDoesNotPreventAChildFromReplacingValidation, testGrandchildCanChangeRulesThroughAnAbstractIntermediate, testChildCanOverrideSafeWithoutChangingValidated, and testAnonymousChildCanChangeTheParentContract | tests/form-request/polymorphism.php, FormRequestTypeRegistryTest::testReceiverIncludesEveryKnownConcreteContract, and FormRequestResultCacheTest::testDescendantChangesInvalidateCachedParentCaller |
Generated fixtures under tests/fixtures add broad
coverage from Laravel’s own validation tests and record exact upstream
provenance. A fixture proves that an inferred type accepts an observed
successful output; it does not prove that one observation exhausts every value
a rule can accept. Focused adversarial tests support the specific claims above.
Static coverage confirms the emitted type. Runtime coverage checks Laravel’s actual successful output. Expected types are changed only after checking Laravel behavior, and runtime-only evidence is not presented as completed static support.
Last reviewed: 2026-08-15.
Conclusion
phpstan-laravel-validation is a mitigation for existing applications, not a
vindication of Laravel validation as a foundation for new type-conscious
code. A sound analyzer cannot make Laravel’s runtime contract cleaner than
it is. It can only prevent downstream code from relying on a prettier
fiction.
FormRequest Inference
FormRequest inference is experimental and disabled by default. Enable it
only when you want validated() and supported safe() projections on
conventional FormRequest classes with statically resolvable payloads.
parameters:
phpstanLaravelValidation:
formRequests:
enabled: true
What is inferred
The extension resolves statically available return expressions from rules()
and applies that shape to whole-payload and supported keyed validated()
calls:
final class StorePersonRequest extends \Illuminate\Foundation\Http\FormRequest
{
public function rules(): array
{
return [
'name' => 'required|string',
'age' => 'integer',
];
}
}
function store(StorePersonRequest $request): void
{
\PHPStan\dumpType($request->validated());
// array{name: string, age?: float|int|string|Stringable|true}
\PHPStan\dumpType($request->safe(['name']));
// array{name: string}
}
Literal returns, resolvable branches, inherited or trait-provided methods, class constants, typed method parameters, and declared custom-rule contracts can participate. If any possible return expression cannot be resolved, the call keeps Laravel’s broad return type rather than inferring from only part of the method.
Inherited rules that depend on late-bound static:: or $this:: references
stay broad. Calls that compose another rules body, such as
array_merge(parent::rules(), [...]), are not expanded unless PHPStan can
expose the complete constant result.
Receiver types
A parent-typed receiver can hold a child with different rules or lifecycle
hooks. The extension considers the concrete class and its discovered
descendants. A request with no discovered descendants retains its own shape
without requiring final. Identical contracts retain that shape; different
contracts form a union. An abstract receiver can use the contracts of its
known concrete descendants.
For example, if a parent requires value to be a string and a child requires
an array, the parent receiver’s validated('value') type is array|string.
The child receiver retains array when its own descendants allow it.
Every possible concrete class must pass the rule-resolution and lifecycle
checks. An unsafe or unresolved possibility makes the receiver fall back to
Laravel’s declared type. A discovered anonymous child also causes a fallback
because its contract is not resolved. A child override of safe() prevents
parent safe() inference without discarding an otherwise valid validated()
contract. A final rules() method does not prevent lifecycle overrides, and
PHPDoc @final does not hide discovered descendants.
This model assumes the discovery scope covers the request implementations used by the application. Undiscovered vendor classes and dynamically generated subclasses can violate that assumption. Include relevant sources in PHPStan’s analysed or scan paths, or explicitly list the request classes. Inference does not prove that an open class has no other runtime descendants.
Lifecycle hooks
FormRequest is a validator lifecycle, not just a rules() method.
Inference falls back when the request overrides validated(),
getValidatorInstance(), createDefaultValidator(), validationRules(),
or passedValidation(), or declares validator(), a non-empty
withValidator(), or after(). Those hooks can replace the validator,
mutate its rules, or change what validated() returns.
A userland withValidator() body with no executable statements is a no-op,
including when inherited or provided by a trait. Any executable statement, or
a body the parser cannot verify, restores the conservative fallback.
Trusted classes
A project can assert that a particular class’s lifecycle hooks do not
invalidate its rules() contract:
parameters:
phpstanLaravelValidation:
formRequests:
trustedClasses:
- App\Http\Requests\StorePersonRequest
Trust is exact: subclasses are checked separately and are not trusted
implicitly. Trust bypasses lifecycle-hook checks for the configured class.
It does not make unresolved rule expressions resolvable and does not override
a custom validated() implementation. A false trust declaration can produce
an unsound type.
Discovery
FormRequest inference does not require Larastan. Request classes are
discovered from PHPStan’s analysed and scan paths and from the root project’s
Composer autoload and autoload-dev PSR-0, PSR-4, and classmap mappings.
Composer discovery reads .php sources. Classes outside this scope, including
undiscovered vendor requests, retain Laravel’s broad type.
Explicit analysed files and scanFiles entries are read regardless of their
extension. PHPStan directory paths use the configured fileExtensions list.
Discovery follows directory symlinks in PHPStan paths and Composer source mappings.
If a source directory cannot be read or a source symlink cannot be resolved,
non-final receivers retain the broad fallback until source discovery can complete.
Use additionalClasses to discover exact classes outside those paths without
weakening lifecycle checks:
parameters:
phpstanLaravelValidation:
formRequests:
additionalClasses:
- Vendor\Package\SomeRequest
The list does not implicitly include subclasses. A configured class with an
unsafe lifecycle hook still retains Laravel’s broad type. Adding an exact
class to trustedClasses also makes it discoverable, but additionally asserts
that its lifecycle hooks are safe enough to bypass those checks.
validated($key) and safe()
Store the terminal array, not the mutable ValidatedInput wrapper, when the
validated shape matters to downstream analysis:
$validated = $request->safe()->all();
$selected = $request->safe(['name', 'age']);
$wrapper = $request->safe();
$validatedLater = $wrapper->all(); // plain array
The first two expressions retain the inferred shape. The last deliberately
does not. Laravel permits array-offset and property writes and unsets on a
ValidatedInput, and every alias observes those mutations. A wrapper can also
escape to code that PHPStan does not analyse. Preserving an old payload shape
on the stored object would therefore turn an ordinary refactor into an unsound
type promise.
Constant string and integer keys, ordinary dotted paths, finite constant-key
unions, and explicit defaults participate in validated($key, $default)
inference. Optional paths include the default type; an omitted default is
null. Dynamic keys, wildcard or first/last traversal, segment arrays,
object-property traversal, and Closure defaults remain mixed.
Constant string and integer paths passed to safe([...]) are projected from
the same validated shape. Direct safe()->all(), safe()->toArray(),
safe()->only([...]), and safe()->except([...]) chains retain or project
that shape for registry-verified FormRequests. only() and except() follow
Laravel’s dotted-path behavior; a literal top-level key containing a dot takes
precedence when except() removes a key. Before Laravel 13.24, Arr::forget()
can retain a nested-array reference between selectors. Multi-selector
except() calls therefore remain broad when an earlier dotted traversal could
change the meaning of a later selector.
Direct safe()->merge([...]) chains also preserve the inferred payload for
subsequent all(), toArray(), only(), and except() calls when the merged
array has a statically bounded shape. This models Laravel’s shallow
array_merge() behavior: later string keys replace earlier values and numeric
keys in direct array expressions are appended and reindexed. PHPStan array
shapes do not guarantee the insertion order of multiple integer keys, so
bounded variables with ambiguous numeric ordering remain broad. Dynamic merge
arrays, state-changing arguments, and stored mutable wrappers also remain
broad.
Validator instances retain Laravel’s declared safe() types because
Factory::resolver() may return a custom Validator whose virtual
validated() implementation changes the payload. The ValidatedInput
wrapper is mutable: Laravel exposes array-offset and property writes and
unsets. Once a wrapper is stored in a variable, later accessors keep
Laravel’s broad declared array type. merge() returns a new wrapper rather
than mutating the original, but storing either wrapper still creates the same
alias and escape problem. Dynamic selectors and selector expressions that may
execute user code also remain broad.
Residual assumptions
The inferred contract assumes callers do not replace a FormRequest’s resolved
validator through the inherited public setValidator() method before calling
validated() or safe(). A custom safe() override retains its declared
return type.
Configuration keys are documented in Configuration.
Custom Validation Rules
Unknown custom rule objects and closures do not prevent inference for the rest of a statically resolvable rule set. They contribute no value narrowing, so adjacent built-in rules remain useful:
$validated = Validator::make($input, [
'reference' => ['required', 'string', new ValidReference()],
])->validated();
\PHPStan\dumpType($validated);
// array{reference: string}
When a custom rule has a trustworthy static contract, declare the upper bound of the original values it can preserve after successful validation.
@phpstan-assert on validate() is not read. Use one of the contracts
below.
Attribute
use jbboehr\PhpstanLaravelValidation\Attribute\ValidationRuleType;
#[ValidationRuleType('non-empty-string')]
final class ValidReference implements \Illuminate\Contracts\Validation\ValidationRule
{
// ...
}
PHPDoc
/** @laravel-validation-type non-empty-string */
final class ValidReference implements \Illuminate\Contracts\Validation\ValidationRule
{
// ...
}
Configuration
Configuration overrides either class-local declaration. It can also type third-party rule classes or registered string rule names:
parameters:
phpstanLaravelValidation:
customRules:
classes:
App\Rules\ValidReference: non-empty-string
names:
valid_reference: non-empty-string
Precedence is configuration, then the attribute, then PHPDoc. Registered
names are normalized like Laravel rule names, so valid_reference,
valid-reference, and ValidReference use the same configured contract.
Malformed configuration, attribute, or PHPDoc contracts fail analysis when
the corresponding rule is encountered. They are not silently widened to
mixed.
What a contract does not do
These are value-only contracts. They do not:
- make a field required;
- declare a rule implicit;
- transform a value; or
- control output projection.
An optional custom int rule still produces int|string because a blank
string may bypass a non-implicit rule. Combining it with required produces
int.
The extension assumes custom rules act as predicates and preserve the input value. A contract is unsound if the rule accepts values outside its declared type or mutates validator data, rules, or validated output.
Stringable builders, conditional builders, and nested rule builders may encode presence or projection behavior. When their structure cannot be recovered statically, the affected path is widened rather than treated as an ordinary value predicate. See Static Resolvability.
Larastan is not required. The extension does not boot the application to discover registered aliases.
Configuration
All options live under parameters.phpstanLaravelValidation. Defaults match
Laravel’s ordinary factory and validator behavior.
parameters:
phpstanLaravelValidation:
laravelVersion: auto
assumeHttpInputNormalization: false
includeUnvalidatedArrayKeys: false
experimentalConditionalPresenceInference: false
formRequests:
enabled: false
additionalClasses: []
trustedClasses: []
customRules:
classes: []
names: []
laravelVersion
Default: auto.
auto uses Composer’s installed-version data for the project root matching
PHPStan’s working directory. That follows the Laravel code actually installed
for analysis rather than a potentially stale lockfile. If no matching
installed-package data is available, the extension falls back to the analyzed
project’s composer.lock. It does not use Laravel versions from unrelated
Composer roots that happen to be loaded in the PHPStan process.
The detected laravel/framework version selects verified release boundaries
such as integer:strict, ascii, and Laravel’s default request-trimming
exceptions. A standalone illuminate/validation installation can select
rule-level behavior, but cannot establish full-framework middleware defaults.
It also cannot select version-sensitive rule-name whitespace behavior because
illuminate/support can have a different version. See
Validation Rule Reference.
For monorepos or other layouts where PHPStan’s working directory is not the relevant Composer project root, set the version explicitly:
parameters:
phpstanLaravelValidation:
laravelVersion: '13.4.0'
If the version is unavailable, malformed, or outside the supported Laravel 10–13 range, inference retains the conservative cross-version type. The effective version context participates in PHPStan’s result-cache metadata, so changing Laravel versions invalidates cached inference.
Boundaries are listed in Laravel Version Behavior.
assumeHttpInputNormalization
Default: false.
By default the extension models the validator itself and therefore includes
blank strings that can bypass optional non-implicit rules. Applications whose
request validation is guaranteed to run after Laravel’s standard
TrimStrings and ConvertEmptyStringsToNull middleware may opt into
narrower request types:
parameters:
phpstanLaravelValidation:
assumeHttpInputNormalization: true
This option affects Request::validate(), controller validate(), and
inferred FormRequest::validated() calls. It does not affect direct
validators, factories, facades, or validators passed to validateWith().
An optional array field normally has the value type array|string because
a blank string may bypass the rule. With this option it has type array;
nullable|array has type array|null. Laravel 11 through 13 exclude
current_password, password, and password_confirmation from trimming by
default, so those paths still include strings. Laravel 10 trims them and
receives the narrower type. If a supported full-framework version cannot be
established, the extension conservatively includes strings.
Enable this option only if neither middleware is skipped or removed and
validation cannot observe values introduced afterward by request mutation.
Projects with custom trimming exceptions or skipWhen() callbacks should
leave it disabled.
includeUnvalidatedArrayKeys
Default: false, matching Laravel’s factory default.
Laravel’s validation factory excludes unvalidated nested array keys by
default. An application that calls includeUnvalidatedArrayKeys() changes
the shape returned from bare array and, where supported by Laravel, list
parents with nested child rules.
parameters:
phpstanLaravelValidation:
includeUnvalidatedArrayKeys: true
When enabled, the extension conservatively widens affected nested parents
because unmentioned keys may survive in validated(). This applies to
inferred factory, facade, request, controller, validator-helper, and
FormRequest output. Affected bare array parents widen to array, so the
inferred types of their validated children are no longer retained. Bare
list parents retain only their listness unless a direct exclusion rule can
remove an element.
The extension does not boot the application or attempt to discover a call in
a service provider. Treat this option as an assertion about the factories
whose output the extension infers. It is not a conservative setting for mixed
factory modes. In particular, an excluding factory can reconstruct a bare
list parent with sparse keys. An including factory normally preserves the
parent, but nested exclusion rules mutate its data before validated()
reads it and can also make the result sparse. The extension widens parents
with direct exclusion rules to cover that behavior. A single global option
cannot precisely model mixed factory modes. A directly constructed
Illuminate\Validation\Validator retains Laravel’s broad declared return
type and is not narrowed from this assumption.
PHPStan reports laravelValidation.unvalidatedArrayKeysConfiguration when a
direct Factory method call or statically resolved Validator facade call
switches to the mode opposite this option. The diagnostic is call-local: it
does not execute service providers, follow arbitrary container aliases, or
claim to determine the final mode after later calls. The option remains the
source of truth for inferred output.
experimentalConditionalPresenceInference
Default: false.
Laravel’s dependent presence rules normally leave the affected output key optional because their result depends on another runtime value. This option recovers definite cases where a required top-level controlling field has a finite scalar-literal type.
parameters:
phpstanLaravelValidation:
experimentalConditionalPresenceInference: true
$validated = Validator::make($input, [
'mode' => 'required|string|in:create',
'name' => 'present_if:mode,create|string',
])->validated();
\PHPStan\dumpType($validated);
// array{mode: 'create', name: string}
The option handles definite outcomes for present_if, present_unless,
missing_if, and missing_unless. Active presence requires the key but
still permits blank strings to bypass adjacent non-implicit rules; it is not
treated as required. Active missing rules omit the key from successful
output. present_if and present_unless refinement requires a detected
Laravel version of 10.32 or later; earlier or unknown versions remain
conservative.
The first experimental slice supports only one conditional field whose controller is a required direct top-level sibling. The controller’s entire inferred domain must either match or not match the dependent values. Mixed matching and non-matching domains, boolean controllers, nested or wildcard paths, multiple conditional fields, exclusions, and custom or opaque rules retain the ordinary conservative optional shape.
See Presence and Output Projection.
Form requests
See FormRequest Inference for behavior. The keys are:
parameters:
phpstanLaravelValidation:
formRequests:
enabled: false
additionalClasses: []
trustedClasses: []
additionalClasses is an exact discovery-only class list for FormRequests
outside the ordinary analysed, scan, and root Composer source paths. Each
class still has to pass the lifecycle-safety checks described in the guide.
The manifest fingerprints the configured class’s Composer package PHP sources
and declared autoload paths. It does the same for parent classes, implemented
interfaces, recursively used traits, and statically referenced classes in the
rules() return expressions, including injected class-constant receivers and
transitive class-constant references. Statically referenced user constants add
their defining file and the runtime-loaded file set as a conservative boundary
for initializer dependencies; functions contribute their available source
files. Exact autoload.files entries are included even when they do not use a
.php extension. These additional sources do not make unlisted classes
discoverable.
trustedClasses is an exact class list. Subclasses are not trusted
implicitly. Trust also makes the class discoverable, but bypasses those
lifecycle checks and can therefore make inference unsound when asserted
incorrectly. Trust in a parent does not bypass checks on its descendants.
Receiver inference includes discovered descendants and assumes discovery covers the application’s request implementations. Hierarchy membership and polymorphic payload changes can invalidate the global result cache, because parent-typed callers do not otherwise depend on child classes. Literal-rule leaves and hierarchies whose combined payload matches the parent’s own exportable contract retain selective invalidation.
For a discovered FormRequest whose rules() method is a single literal return,
the extension records each relevant class or trait method body in PHPStan’s
exported cache state when the complete application-owned request hierarchy is
part of the current analysis selection and its relevant method markers are
exportable by PHPStan. The extension enumerates the current analysis roots
using PHPStan’s file-selection semantics, including its hidden and
version-control directory exclusions, rather than treating every path
descendant as analysed. A rule-body change can then invalidate that request
and its dependent callers without discarding unrelated cached files. A
redundant additionalClasses entry does not change this cache strategy.
Trusted requests, requests discovered only through additionalClasses,
sources omitted by a CLI path override, excluded or otherwise unanalysed
hierarchy sources, non-exportable private trait methods, and rule expressions
involving constants, builders, helpers, services, or other external behavior
retain the manifest’s global descriptor fallback. The manifest remembers
directly resolved external source fingerprints that are not part of its
ordinary project scan. This split is conservative: unsupported provenance
causes broader invalidation rather than risking a stale inferred type.
Custom rules
parameters:
phpstanLaravelValidation:
customRules:
classes: []
names: []
Diagnostics
Validator-contract invalidation operates independently of the inference
options. PHPStan reports mutations of existing inferred validators under
laravelValidation.validatorMutation while allowing conservative or precise
handling of supported fresh chains. See
Supported Entry Points.
Statically resolvable parsing rules on a detected laravel/framework version
below 10.7 are reported as
laravelValidation.parsingRuleLaravelVersion. The analyzer remains silent
when the framework version is unavailable; the parser’s runtime capability
guard still rejects validators without setValue().
Numeric parsing rules combined with min, max, between, or size without
Laravel’s integer, numeric, or decimal marker are reported as
laravelValidation.parsingNumericSize. Laravel otherwise measures the
original representation rather than the parsed numeric value. For a
float-producing parser, the diagnostic recommends numeric or an appropriate
decimal rule rather than integer, which rejects non-integral values. The
diagnostic inspects supported factory, facade, helper, request, and controller
validation calls, including validateWith() and validateWithBag(), as well
as returns from FormRequest::rules(). FormRequest rules are checked
independently of formRequests.enabled because the diagnostic concerns runtime
behavior rather than inferred FormRequest output. See
Parsing validated output.
Supported Entry Points
The same rule-set inference applies to these statically resolvable calls.
| Entry point | Inferred return |
|---|---|
Validator::make($data, $rules)->validated() | Validated shape |
Factory::make($data, $rules)->validated() | Validated shape |
Factory::validate($data, $rules) | Validated shape |
Validator::validate($data, $rules) facade | Validated shape |
Request::validate($rules) | Validated shape |
Controller $this->validate($request, $rules) | Validated shape |
validator($data, $rules)->validated() helper | Validated shape |
FormRequest::validated() / supported safe() | Validated shape or union of discovered concrete contracts when FormRequest inference is enabled |
Named data and rules arguments are supported. Dynamic rule sets retain
Laravel’s broad declared return types. Calls that supply the relevant
argument only through ... unpacking also retain those broad types rather
than guessing which unpacked element contains the rules.
If the input does not match the rules, Laravel throws
Illuminate\Validation\ValidationException. For successful input, the
extension infers the values and shape Laravel may preserve.
A directly constructed Illuminate\Validation\Validator retains Laravel’s
broad declared return type and is not narrowed from configuration
assumptions such as includeUnvalidatedArrayKeys.
Validator mutation and contract invalidation
Laravel validators are mutable, but Laravel does not clear their previous
validation message state when their data or rules change. After validation has
run, validated() can therefore return data that was never checked against the
current rules, or reject valid data because an earlier input failed.
The extension tracks these mutation methods:
setData();setValue();setRules();addRules();- imperative
$validator->sometimes().
When one of these methods is called on an existing validator carrying an
inferred rule contract, PHPStan reports
laravelValidation.validatorMutation. When the return value of setData(),
setRules(), or sometimes() is used, that returned contract is also widened
to a plain validator unless the call is one of the fresh cases below.
The sometimes validation rule remains supported. This behavior concerns only
the method that mutates an existing validator.
Two syntactically fresh cases are safe enough to retain useful inference:
setData()chained directly from a statically resolved factory, facade, orvalidator()helper call retains the freshly constructed validator’s rule contract;- a statically resolvable
setRules()chain from the same fresh entry points receives the replacement rules’ inferred contract.
Other returned mutation chains are broad. Calling setRules() through a
variable is broad even when its argument is constant: static analysis cannot
prove that the validator has not already validated, cached a result, or been
aliased.
Broadly typed Laravel validators do not carry a contract for this extension to
invalidate, so their mutations are not diagnosed. This includes ordinary
FormRequest::withValidator() hooks: non-empty lifecycle hooks already make
FormRequest inference fall back unless the affected concrete request class is
explicitly trusted.
Constructing a new validator with complete data and rules remains the clearest
general solution.
The experimental Rensei runtime uses setValue() internally during its
invariant-checked final write-back. Calls made from the package-owned
BaseParsingRule implementation are exempt; subclasses and application code
are not.
Return-value widening cannot change the type of an ignored receiver or another alias. Suppressing the diagnostic can therefore make later analysis unsound. PHPStan has no general object-identity or validator-typestate model with which to invalidate every reference to the mutable object.
Input refinement
A successful direct facade or Factory::validate() call can refine safe,
statically resolvable top-level fields in the caller’s original array.
/** @var array<string, mixed> $input */
Validator::validate($input, ['name' => 'required|string']);
\PHPStan\dumpType($input['name']); // string
This is an input constraint, not a claim that the original array was
replaced by validated() output. Unrelated input keys may still exist.
Refinement is limited to:
- a simple input variable;
- arguments whose evaluation is known not to mutate program state.
The following are not used to narrow the caller’s array:
- nested and wildcard paths;
- exclusion and missing rules;
- rule sets containing custom or opaque runtime behavior.
Guaranteed fields are added. An optional field is narrowed only when the input’s existing type already proves that the field is present.
This post-call refinement assumes Laravel’s ordinary factory and validator execution. Application-defined replacements for that execution path can invalidate the inferred constraint.
Static Resolvability
Precise inference requires a complete, statically visible rule expression. When that expression is not available, the extension falls back conservatively instead of guessing.
This page states the shared rule. Individual rule builders record only exceptions and version boundaries.
What must be visible
The extension can recover built-in semantics from:
- string rules and arrays of string rules;
- fresh inline factory calls such as
Rule::in(['draft']); - exact construction of the supported concrete builder classes;
- statically visible constructor arguments, enum cases, and declared fluent methods on those builders.
The call, class name, method name, and arguments that affect the serialized rule must be visible in the expression PHPStan is analysing.
Shared conservative fallbacks
These forms stay conservative for every builder unless a builder entry says otherwise:
| Expression | Why it is conservative |
|---|---|
| Assigned or stored builder object | PHPStan retains the class, not constructor arguments or later mutation |
| Subclass of a supported builder | The subclass may change serialization or fluent behavior |
Dynamic class or method name ($class::in(), Rule::$method(), new $class) | The resolved callee is not a proven supported factory |
Unpacked arguments (Rule::in(...$values)) | The argument list is not a closed static list |
| Dynamic or non-constant arguments | The serialized rule can change at runtime |
Arrayable or runtime Stringable arguments | Analysis does not execute toArray() or __toString() |
First-class callables (Rule::in(...)) | No argument list is supplied |
Fluent ->when() / ->unless() on a builder | The selected state is a runtime program |
| Macros and unknown fluent methods | No generally available static contract |
Closures, Rule::forEach, and NestedRules | Runtime callbacks supply the rules |
A conservative result is typically optional mixed for the affected path,
or Laravel’s declared return type for the whole call. Adjacent statically
visible built-in rules on the same path still contribute.
Lookalike factories
A static method that happens to be named in or array on another class is
not Illuminate\Validation\Rule. Those calls stay conservative.
Empty and false builder conditions
Literal-boolean Rule::requiredIf(), excludeIf(), prohibitedIf(),
when(), and unless() are recovered when the condition is a statically
known boolean. Callback and non-constant conditions are runtime programs.
See Rule Builders.
Custom predicates without a contract
An unknown custom rule object or closure does not wipe adjacent built-in rules. It contributes no value narrowing unless a custom-rule contract is declared.
Validation Rule Reference
This page is a lookup table for built-in string rules. It records the accepted-value type the extension emits after successful validation.
Presence, exclusion, and nested reconstruction are separate from the value type. See Presence and Output Projection. Fluent objects are in Rule Builders. Shared conservative fallbacks are in Static Resolvability. The inventory and status counts are in Validation Rule Coverage.
int and bool aliases normalize to integer and boolean before type
resolution, matching Laravel’s ValidationRuleParser::normalizeRule().
Rule-name whitespace follows the selected Laravel version. Internal tabs and additional Unicode whitespace become word separators in Laravel 11.45.2 and 12.21.0. Laravel 12.0–12.20 retains the earlier behavior. From 13.9.0, U+180E is also a separator. Parameters retain their original whitespace.
Without a known framework version, names affected by these differences are
opaque. This also applies to automatically detected standalone Validation
components, whose Support dependency can have a different version. An explicit
laravelVersion selects the corresponding
normalization behavior. Configured custom-rule names use the same normalization.
Ambiguous names require a known framework version; empty names and collisions
with built-in rules or other custom names are rejected.
Before a named rule exists in the detected Laravel version, the same spelling
may be an application alias. Inference then stays mixed.
Optional non-implicit rules still admit a blank string unless required,
present, HTTP-normalization, or another implicit rule prevents that bypass.
Exact accepted sets
| Rule | Successful native type |
|---|---|
accepted | 'yes'|'on'|'1'|1|'true'|true |
declined | 'no'|'off'|'0'|0|'false'|false |
boolean | bool|0|1|'0'|'1' |
in:... | Parameter-aware union of values Laravel can accept and preserve through loose string comparison |
Numeric in parameters can narrow representable native integers to
literals. They retain broad float, numeric-string, and Stringable
branches. A float parameter also retains broad int because PHP’s
precision can change the serialized spelling.
Native strings
| Rule | Successful native type |
|---|---|
string, lowercase, uppercase | string |
email, alpha, url, uuid, ulid, ip, ipv4, ipv6, mac_address, timezone, active_url | non-empty-string |
current_password contributes mixed: Laravel delegates acceptance to the
configured hasher and preserves the original native value. Add string to
require a native string. See the runtime example.
Coercive text
These rules admit values Laravel stringifies for the check, then preserve the original native value.
| Rule | Successful native type |
|---|---|
alpha_dash | float|int|non-empty-string |
alpha_num | float|int<0, max>|non-empty-string |
json | float|int|non-empty-string|Stringable|true |
regex, not_regex | float|int|string |
ascii | string from Laravel 13.4; earlier supported releases preserve a broad weakly coerced union |
hex_color | non-empty-string from Laravel 13.4; non-empty-string|Stringable from 10.33 through 13.3; mixed before 10.33 |
base64 | non-empty-string from Laravel 13.21; mixed before that |
Dates
| Rule | Successful native type |
|---|---|
date, date_equals, after, after_or_equal, before, before_or_equal | DateTimeInterface|float|int|non-empty-string |
date_format | non-empty-string when every statically known format cannot produce a PHP numeric string; otherwise float|int|non-empty-string (date_format rejects DateTimeInterface) |
These rules do not parse input into a canonical date object. For example,
date_format:Y-m-d narrows to non-empty-string, while date_format:Ymd
and a format list containing Ymd retain native numerics because Laravel can
accept and preserve them. Unknown formats remain conservative.
Numbers
| Rule | Successful native type |
|---|---|
numeric, decimal, multiple_of | float|int|numeric-string |
digits | float|int|numeric-string from Laravel 12.35; bool|float|int|numeric-string|Stringable|null before that |
min_digits, max_digits | float|int|numeric-string from Laravel 13.4; bool|float|int|numeric-string|Stringable|null before that |
digits_between | float|int|numeric-string from Laravel 13.6; bool|float|int|numeric-string|Stringable|null before that |
integer | float|int|numeric-string|Stringable|true |
integer:strict | int from Laravel 12.22; earlier releases ignore strict and keep the ordinary integer union |
Digit-count rules keep the broader family when the Laravel version is unknown
or unsupported. That family does not refine booleans or null by digit bounds.
The version boundaries
concern the predicates themselves: optional blank strings and nullable null
values can still bypass them after the native-type guards were introduced.
PHPStan cannot express “integral floats only.” The non-strict numeric unions are therefore broader than Laravel’s successful subset and still sound.
Arrays
| Rule | Successful native type |
|---|---|
array | array |
array:name,email | array{name?: mixed, email?: mixed} |
array_keys:... | Optional-key shape from Laravel 13.24; mixed before that |
list | list<mixed> from Laravel 11.0.3; mixed before that |
required_array_keys:name | array intersected with a required name offset |
contains, doesnt_contain, in_array_keys | array from Laravel 11.8, 12.22, and 12.16 respectively; mixed before those releases |
Bare array and, from Laravel 11.23, literal list also decide nested
reconstruction. See Presence and Output Projection.
Files
| Rule | Successful native type |
|---|---|
file, image, mimes, mimetypes, dimensions | Symfony\Component\HttpFoundation\File\File |
extensions | Same Symfony File type from Laravel 10.34; mixed before that |
encoding | Broad preserved union from Laravel 12.40; mixed before that |
Laravel validates and preserves the original file. It does not construct a separate dimensions or MIME value.
Enum
A string enum rule is conservative unless the enum class is recovered from
a fresh Enum builder. The builder includes
statically visible cases, backing values, and weakly coerced native values
Laravel can accept and preserve.
Neutral rules
These names are recognized. They contribute no local accepted-value type. Adjacent value rules remain responsible for the native family.
| Family | Rules |
|---|---|
| Size and comparison | between, gt, gte, lt, lte, max, min, size |
| Cross-field and domain predicates | accepted_if, confirmed, declined_if, different, distinct, doesnt_end_with, doesnt_start_with, ends_with, exists, filled, in_array, not_in, password, same, starts_with, unique |
| Flow, presence, and projection | bail, exclude, exclude_if, exclude_unless, exclude_with, exclude_without, missing, missing_if, missing_unless, nullable, present, present_if, present_unless, prohibited, prohibited_if, prohibited_unless, prohibits, required, required_if, required_unless, required_with, required_with_all, required_without, required_without_all, sometimes |
Neutral does not mean ignored. required, present, missing, nullable,
sometimes, and exclude* have tree-level handling. min can refine an
already known string or collection to its non-empty form when the parameter
is definitely positive.
not_in is type-neutral because PHPStan has no useful general complement
for Laravel’s loose comparison. A fresh Rule::notIn() builder is therefore
not a value-narrowing rule.
Conservative mixed fallbacks
These reserved names have no built-in accepted-value model:
missing_with, missing_with_all, present_with, present_with_all,
required_if_accepted, required_if_declined, prohibited_if_accepted,
prohibited_if_declined.
They remain optional or mixed rather than inventing a correlated union
over another field.
Adjacent-rule refinement
Adding a native-family rule intersects the unions:
$validated = Validator::make($input, [
'age' => 'required|integer|string',
])->validated();
\PHPStan\dumpType($validated);
// array{age: numeric-string}
Rule Builders
Fresh inline factory calls and exact construction of the listed classes can recover the equivalent built-in rule. Shared conservative cases are in Static Resolvability. This page records support, version boundaries, and behavior unique to each API.
Enum
Fresh Rule::enum(Status::class) and
new Illuminate\Validation\Rules\Enum(Status::class) recover the enum class
and literal filter state.
$validated = Validator::make($input, [
'status' => ['required', Rule::enum(Status::class)->only(Status::Published)],
])->validated();
\PHPStan\dumpType($validated);
// array{status: Status::Published}
Literal only() and except() calls are modeled from Laravel 10.46.
Backed enums also include the original backing values and weakly coerced
native values that Laravel can accept and preserve. They are not assumed to
return only enum objects.
Rule::in()
Fresh Rule::in() calls with literal scalar values recover the equivalent
parameterized in rule.
$validated = Validator::make($input, [
'status' => ['required', Rule::in(['draft', 'published'])],
])->validated();
\PHPStan\dumpType($validated);
// array{status: 'draft'|'published'|Stringable}
The union includes every native value Laravel can accept and preserve through
its loose string comparison. Numeric parameters can narrow safely
representable native integers to literals, but retain broad float,
numeric-string, and Stringable branches. A builder containing a float
also retains broad int: application code can change PHP’s precision
before Laravel stringifies the builder.
From Laravel 10.21.1, literal enum arguments are serialized to their case
names or backing values. Rule::in([Status::Draft]) is not an enum-object
rule.
Exact fresh new In([...]) matches the factory. Laravel 10.36 expands the
constructor to accept scalar and variadic inputs.
Rule::notIn()
Fresh Rule::notIn() calls are a type-neutral not_in predicate. Adjacent
value and presence rules remain responsible for the useful type.
$validated = Validator::make($input, [
'role' => ['required', 'string', Rule::notIn(['admin'])],
])->validated();
\PHPStan\dumpType($validated);
// array{role: string}
The extension does not express “every string except admin.” Because the
forbidden set does not affect this neutral contribution, its expression may
be dynamic while a fresh factory call or exact new NotIn(...) remains
visible. Direct array construction works throughout the supported range;
scalar, variadic, and Arrayable constructor inputs begin in Laravel 10.36.
Literal conditional builders
Fresh Rule::requiredIf(), Rule::excludeIf(), and Rule::prohibitedIf()
calls with a statically known boolean become the corresponding unconditional
required, exclude, or prohibited rule. Exact new RequiredIf,
ExcludeIf, and ProhibitedIf construction is supported.
$validated = Validator::make($input, [
'name' => ['string', Rule::requiredIf(true)],
'legacy_name' => ['string', Rule::excludeIf(true)],
])->validated();
\PHPStan\dumpType($validated);
// array{name: string}
A false condition serializes to an empty rule that contributes no validation constraint. Adjacent rules remain. Present input is preserved when the empty rule stands alone. The explicit rule path still participates in output projection, so an empty rule on a nested parent can preserve unvalidated sibling keys.
Fresh Rule::when() calls with a statically known boolean expose the
selected string or array branch. Rule::unless() has the same support from
Laravel 10.33, with the condition inverted. Selected rules are flattened
into surrounding rule lists as Laravel flattens them. An empty selected
branch still marks an explicit parent path for nested projection. Nested
conditional wrappers are not recursively expanded because Laravel performs
only one expansion pass.
Rule::array()
Introduced in Laravel 11.7. Fresh calls with statically visible scalar or
enum keys recover the equivalent array rule.
$validated = Validator::make($input, [
'payload' => ['required', Rule::array(['name', 'email'])],
])->validated();
\PHPStan\dumpType($validated);
// array{payload: array{name?: mixed, email?: mixed}}
Rule::array() and Rule::array([]) serialize to a bare array rule, so
nested child rules rebuild the returned parent. A non-empty key list
preserves the complete permitted parent. Explicit null serializes to
array: and permits only the empty-string key.
Unquoted comma joining is lossy: Rule::array(['a,b']) becomes array:a,b
and permits keys a and b. Float keys remain conservative because PHP’s
runtime precision can change the serialized spelling.
Exact fresh new ArrayRule(...) matches the factory.
Rule::arrayKeys()
Introduced in Laravel 13.24. Fresh calls recover the equivalent
array_keys rule.
Unlike bare array, this rule preserves the complete permitted parent around
nested child rules. Commas split parameters. An empty key list becomes
array_keys: and permits the empty-string key. Float keys remain
conservative for the same precision reason as Rule::array().
Exact fresh new ArrayKeys(...) matches the factory.
Array predicates
Laravel 12.16 introduced Rule::contains() and Contains. Laravel 12.22
introduced Rule::doesntContain() and DoesntContain. Fresh factory calls
and exact direct construction recover the built-in array predicate.
$validated = Validator::make($input, [
'features' => ['required', Rule::contains('search')],
'roles' => ['required', Rule::doesntContain('blocked')],
])->validated();
\PHPStan\dumpType($validated);
// array{features: array, roles: array}
Laravel checks and preserves the original array. These builders do not describe its keys or values.
Numeric builders
Laravel 11.42 introduced Rule::numeric() and Numeric. Fresh factory
calls, direct construction, and declared predicate methods retain Laravel’s
preserved numeric representations.
$validated = Validator::make($input, [
'amount' => ['required', Rule::numeric()->between(1, 100)],
'count' => ['required', Rule::numeric()->integer(strict: true)],
])->validated();
\PHPStan\dumpType($validated);
// Laravel 12.55+: array{amount: float|int|numeric-string, count: int}
Non-strict integer(), digits(), digitsBetween(), and exactly() do
not imply a native int. Laravel 12.55 adds integer(strict: true), which
does justify int. Earlier releases ignore a positional boolean passed to
integer(). Other fluent methods constrain which values pass without
changing their possible native PHP representations.
String builders
Laravel 12.55 introduced Rule::string() and StringRule. Fresh factory
calls, direct construction, and declared predicate methods infer a native
string.
The builder begins with Laravel’s native string rule. Fluent predicates
constrain contents or length but do not convert other values into strings.
Inference currently recovers that native representation rather than every
content refinement: Rule::string()->min(1) remains string, while
string|min:1 can be refined to non-empty-string.
Date builders
Laravel 11.40 introduced Rule::date(). The parser understood only builders
that serialized to one rule until 11.41 (chains inside rule lists) and
11.43.2 (standalone field rules). At the applicable boundary, fresh factory
calls, direct construction, and declared comparison predicates recover
Laravel’s preserved date family.
format() changes that family because date_format rejects
DateTimeInterface objects.
$validated = Validator::make($input, [
'published_on' => ['required', Rule::date()->format('Y-m-d')],
'deadline' => ['required', Rule::date()->afterToday()],
])->validated();
\PHPStan\dumpType($validated);
// Laravel 11.41+: array{
// published_on: non-empty-string,
// deadline: DateTimeInterface|float|int|non-empty-string
// }
Laravel 12.44 adds Rule::dateTime() and the past(), future(),
nowOrPast(), and nowOrFuture() predicates. dateTime() uses
Y-m-d H:i:s and therefore infers non-empty-string. Formatted builders
follow the same parameter-sensitive contract as date_format: separator
formats such as Y-m-d exclude native numerics, while a numeric format such
as Ymd retains float|int|non-empty-string. Laravel 12.3 changed how
format() serializes, but both forms preserve this contract.
These builders validate and preserve successful input. They do not parse it into a canonical date object.
Dimensions
Fresh Rule::dimensions() and new Dimensions() recover the same Symfony
File type as the dimensions string rule, including width, height, and
ratio constraints. Laravel 11.23 adds minRatio(), maxRatio(), and
ratioBetween(). Laravel validates and preserves the original file.
File builders
Fresh Rule::file(), Rule::imageFile(), File::types(), File::image(),
new File(), and new ImageFile() recover Symfony file predicates. Size,
MIME, extension, encoding, dimension, and additional-rule fluent constraints
retain the same successful native value type.
extensions() begins at Laravel 10.34. encoding() begins at Laravel
12.40.
Late-bound self / parent / static forwarding calls and global
File::default() configuration remain conservative.
Database builders
Fresh Rule::exists() and Rule::unique(), and exact Exists / Unique
construction, are type-neutral predicates. The database query changes
whether validation succeeds. Successful validation preserves the original
input, so an adjacent value rule remains responsible for the native PHP
type.
Supported fluent methods include where*(), soft-delete, query-callback,
and unique-ignore methods. Those methods return the same rule object and do
not transform validated output.
Presence and Output Projection
Presence rules decide whether a key must exist. Projection rules decide
whether a successful path appears in validated() and whether a parent
array is rebuilt or preserved.
Presence
| Rule | Successful output |
|---|---|
required | Key is present. Blank strings fail. |
present | Key is present. Blank strings may bypass adjacent non-implicit rules. |
filled | If the key is present, it is non-blank. |
nullable | null is allowed. Does not make a missing key required. |
sometimes | The key remains optional. |
missing | The path is omitted from successful output. |
exclude | The path is omitted from successful output. |
required and present are not interchangeable. present|integer can
still yield a blank string because integer is non-implicit.
Nested reconstruction
| Parent rule | Nested children |
|---|---|
Bare array or, from Laravel 11.23, literal list | Parent is rebuilt from validated descendants |
array:name,email or array_keys:... | Complete permitted parent is preserved |
includeUnvalidatedArrayKeys: true | Affected bare parents widen; see Configuration |
An empty explicit rule on a nested parent, including a false
Rule::requiredIf(false) or an empty Rule::when() branch, still marks
that path for projection and can preserve unvalidated sibling keys.
Wildcards
Wildcard collections may have integer or string keys. When wildcard and named rules share a parent, inference unions their possible projected value types because it cannot preserve every key correlation.
A required wildcard descendant does not require any match to exist. Zero matches can leave an array parent present with no projected children.
Conditional presence
Dependent rules such as required_if, present_if, and exclude_if
normally leave the affected key optional because the outcome depends on
another runtime value.
The experimental
experimentalConditionalPresenceInference
option can resolve definite present_if, present_unless, missing_if,
and missing_unless outcomes for one top-level field whose controller is a
required sibling with a finite scalar-literal domain.
present_if and present_unless require Laravel 10.32 or later.
Numeric rule keys
Laravel 10 and 11 reindex top-level literal integer rule keys from 0.
Laravel 12 and later preserve them. The extension follows the detected or
configured Laravel version and falls back to a conservative array type when
that version is unavailable.
Literal integer keys in output
Canonical numeric key parameters such as 0 use integer offsets. A
non-canonical numeric-looking key such as 01 remains a string offset.
Limitations
These are current analysis limits and design constraints. Some follow from Laravel or PHPStan’s type system. Others are the extension’s current static-analysis boundaries. Related fallbacks are defined in Static Resolvability.
Value families
Laravel validation generally does not normalize returned values. numeric
produces int|float|numeric-string. If the input is known to be a string,
numeric|string yields numeric-string.
PHPStan cannot express some of Laravel’s successful subsets, such as “integral floats only.” The inferred union is then broader than the runtime set and still sound.
Custom rules
Custom-rule contracts describe accepted values only. Custom implicitness and custom output mutation remain conservative. See Custom Validation Rules.
FormRequest lifecycle
Experimental FormRequest inference is opt-in and assumes its discovery scope
covers the application’s request implementations. It includes known
descendant contracts and falls back for unresolved or unsafe possibilities.
Undiscovered and dynamically generated descendants remain outside that model. It
cannot globally track an inherited setValidator() call that replaces the
validator before validated(). See
FormRequest Inference.
Application execution
The extension does not boot the Laravel application. It does not discover service-provider factory configuration, registered string-rule aliases, or macros by executing application code.
Mixed factory modes
A single includeUnvalidatedArrayKeys option cannot model a process that
uses both including and excluding factories. See
Configuration.
Validator aliases and lifecycle state
Mutation can widen a returned validator value, but PHPStan cannot invalidate an ignored receiver or every alias to the same object. The mutation diagnostic therefore remains part of the soundness boundary for existing inferred validators. If it is suppressed, the receiver or an alias may retain obsolete rule metadata. Laravel’s cached validation state also prevents precise re-inference of a reused validator even when replacement rules are constant. See Supported Entry Points.
What the test suite does not prove
The suite includes pinned Laravel runtime audits, PHP and Laravel matrices, Larastan checks, property tests, and mutation testing. That evidence covers the supported combinations under test. It is not a claim of universal soundness for arbitrary runtime extensions.
Laravel Version Behavior
When laravelVersion is auto or an explicit supported release, inference
follows verified Laravel boundaries. An unavailable, malformed, or
out-of-range version keeps the conservative cross-version type.
The portable audit corpus and focused runtime suites established these boundaries. Detailed evidence is in the Laravel Version Inference Audit.
Contract changes in the portable corpus
| Boundary | Effect |
|---|---|
| Laravel 12.0 | Top-level literal integer rule keys are preserved instead of reindexed from 0 |
| Laravel 12.22 | integer:strict requires a native integer |
| Laravel 12.35 | digits rejects booleans, null, and Stringable objects |
| Laravel 13.4 | ascii requires a native string |
| Laravel 13.4 | min_digits and max_digits reject booleans, null, and Stringable objects |
| Laravel 13.6 | digits_between rejects booleans, null, and Stringable objects |
These digit-count boundaries concern the predicates themselves. Optional blank
strings and nullable null values can still bypass them.
Rules and builders introduced in the supported range
| Version | What changes for inference |
|---|---|
| 10.21.1 | In / NotIn serialize enum cases |
| 10.32 | present_if / present_unless exist; experimental presence refinement may apply |
| 10.33 | hex_color; Rule::unless() |
| 10.34 | extensions |
| 10.36 | In / NotIn constructors accept scalar, variadic, and Arrayable inputs |
| 10.46 | Enum::only() / Enum::except() |
| 11.0 | prohibited_if_accepted, prohibited_if_declined; Laravel 10 trims password fields, 11+ does not |
| 11.0.3 | list; required_if_declined |
| 11.7 | Rule::array() |
| 11.8 | contains |
| 11.23 | Literal list participates in nested reconstruction; Dimensions ratio methods |
| 11.40 | Fluent Date builder |
| 11.41 | Date chains usable inside rule lists |
| 11.42 | Fluent Numeric builder |
| 11.43.2 | Date chains usable as standalone field rules |
| 12.16 | in_array_keys; Rule::contains() |
| 12.22 | doesnt_contain; Rule::doesntContain() |
| 12.40 | encoding |
| 12.44 | Rule::dateTime() and now-relative date predicates |
| 12.55 | Numeric::integer(strict: true); Rule::string() |
| 13.4 | hex_color rejects compatible Stringable objects |
| 13.21 | Native-string-only base64 |
| 13.24 | array_keys; Rule::arrayKeys() |
Before a builder or rule exists, a matching name may be an application macro or a missing validator method. Inference stays conservative there.
How version is chosen
See laravelVersion.
Laravel validation rule coverage survey
This document inventories Laravel’s canonical validation-rule names and its
distinct built-in predicate objects, then maps them to the inference currently
implemented by phpstan-laravel-validation. It is an audit and roadmap, not a
claim that every listed rule is completely or universally modeled.
For a lookup table of emitted types, see Validation Rules.
The important distinction is between a rule that is unknown, a rule that is deliberately type-neutral, and a rule whose value type is modeled while its presence or output behavior is not. Treating all three as merely “supported” would hide the most useful findings.
Scope and evidence
The inventory was derived from Laravel’s validate* methods, Validator rule
classifications, and built-in rule objects at the exact commits represented by
the repository’s generated fixtures:
| Laravel | Fixture source | Trait validators | Notes |
|---|---|---|---|
| 10.50.2 | 3ff39b7a | 102 | Pinned fixture |
| 11.55.0 | dc7ec34a | 107 | Pinned fixture |
| 12.64.0 | 727a8ea2 | 110 | Pinned fixture; 12.65.0 and 12.66.0 have the same rule inventory |
| 13.23.0 | 92a70722 | 111 | Pinned fixture |
| 13.24.0 | 6d481710 | 112 | Pinned boundary fixture; adds array_keys; 13.25.0 has the same rule inventory |
Enum and Password are rule objects rather than validate* methods. With
those included, Laravel 13.25 has 114 canonical rule identities reserved by
TypeResolver::BUILT_IN_RULE_NAMES.
That reserved-name inventory is not the whole public predicate-object surface.
Rule::can() and Rule::anyOf() return distinct built-in rule objects with no
equivalent validate* method or canonical string-rule name. Counting those
objects once gives 116 built-in rule semantics. A follow-up source audit against
Laravel 13.x commit
0189a60
confirmed the same 112 validate* methods plus these object-only rules.
Composition helpers such as Rule::when(), Rule::unless(), and
Rule::forEach() are not additional predicates and are not counted again.
Laravel added these rules and rule-object features during the supported major range:
- Laravel 10.14:
Rule::can()adds the object-onlyCanauthorization predicate; - Laravel 10.21.1:
InandNotInbuilders gain enum-value serialization, and their concrete constructors gain scalar, variadic, andArrayableinputs in 10.36; - Laravel 10.33:
hex_colorandRule::unless(), followed byextensionsin 10.34 andEnum::only()/Enum::except()in 10.46; - Laravel 11:
prohibited_if_accepted,prohibited_if_declined, andrequired_if_declined, followed bylistin Laravel 11.0.3, theRule::array()builder in 11.7, andcontainsin 11.8; Laravel 11.23 later makes a literallistparticipate in nested-output reconstruction and adds the extendedDimensionsratio methods; Laravel 11.40 adds the fluentDatebuilder, 11.41 makes its chains usable inside rule lists, 11.42 adds the fluentNumericbuilder, and 11.43.2 makes Date chains usable as standalone field rules; - Laravel 12.8:
Rule::anyOf()adds an object-only alternative-ruleset predicate; - Laravel 12:
in_array_keysandRule::contains()in 12.16, followed bydoesnt_containandRule::doesntContain()in 12.22,encodingin 12.40,Rule::dateTime()and the date builder’s now-relative predicates in 12.44, and strict integer mode on theNumericbuilder plus the fluentStringRulebuilder in 12.55; - Laravel 13.21:
base64, followed byarray_keysin 13.24.
The generated fixtures contain runtime results from Laravel’s own tests. The focused inference audit adds adversarial witnesses for selected interactions, but neither source inspection nor a finite fixture corpus proves universal soundness. A candidate below still needs a focused runtime probe on every supported major before its inferred type is narrowed.
CustomRulesInferenceTest::testEveryInstalledLaravelAttributeRuleNameIsReservedFromCustomAliases
also reflects the installed ValidatesAttributes trait. The floating Laravel
CI profiles will therefore detect a newly added validate* method even before
the next pinned fixture refresh. It cannot discover a new object-only rule;
those require a separate audit of Illuminate\Validation\Rule and the
classes under Illuminate\Validation\Rules.
Status definitions
The tables use four accepted-value statuses:
- direct: the rule contributes a non-
mixedPHPStan type; - neutral: the rule is explicitly recognized but contributes no local accepted-value type, leaving adjacent rules to determine it;
- mixed: the rule falls through to the conservative default because no built-in accepted-value model exists.
- object-only mixed: Laravel supplies a distinct predicate object outside
the canonical name inventory, and the object currently contributes
mixedthrough the custom-rule path.
These statuses describe only accepted native values. Presence, exclusion, nested projection, wildcard traversal, and correlations with other fields are separate dimensions.
Summary
| Accepted-value handling | Rule forms | Focused static coverage | Meaning |
|---|---|---|---|
| Direct type contribution | 56 | 56 | A native value type is emitted and has dedicated focused static coverage |
| Explicitly neutral | 50 | 19 | The rule does not independently narrow the local value type, whether intentionally or because a correlated model is unavailable |
Conservative mixed fallback | 8 | 0 | No built-in accepted-value model is applied |
Object-only built-in predicate with mixed contribution | 2 | 0 | Can and AnyOf are recognized by their Laravel predicate contracts but have no dedicated built-in extraction |
| Total public rule semantics | 116 | 89 files | 114 reserved identities plus the two distinct object-only predicates |
The first three rows are exactly the 114 names reserved against custom string
aliases. The object-only row does not expand that alias namespace: neither
Can nor AnyOf has a canonical string-rule name for a custom alias to
shadow.
The repository’s generated Laravel fixtures provide broader conformance coverage than the focused-file count suggests. Focused files are still important because they state the intended PHPStan type directly and can include adversarial native values that Laravel’s upstream tests do not exercise.
Rules with direct accepted-value inference
The following 56 names contribute a concrete type today:
| Family | Rules | Current contribution |
|---|---|---|
| Exact accepted sets | Accepted, Boolean, Declined, In | Literal unions or parameter-aware scalar unions; numeric In parameters narrow safely representable native integers while retaining broader float, numeric-string, and object equivalence classes; fresh inline Rule::in() builders can supply the parameters, with float-bearing builders retaining int for runtime precision changes |
| String predicates | ActiveUrl, Alpha, Email, Ip, Ipv4, Ipv6, MacAddress, Timezone, Ulid, Url, Uuid | Usually non-empty-string |
| Native string checks | Lowercase, String, Uppercase | string |
| Coercive text checks | AlphaDash, AlphaNum, Json, NotRegex, Regex | Unions containing the native scalar or Stringable values Laravel preserves |
| Date checks | After, AfterOrEqual, Before, BeforeOrEqual, Date, DateEquals, DateFormat | Numeric scalars, non-empty strings, and where applicable DateTimeInterface; DateFormat removes native numerics when every known format proves they cannot satisfy Laravel’s weakly coerced comparison |
| Numeric checks | Decimal, Digits, DigitsBetween, Integer, MaxDigits, MinDigits, MultipleOf, Numeric | Version-aware unions of the native values Laravel accepts and preserves; see Numbers for coercive predicates and their broader families |
| Arrays and files | Array, RequiredArrayKeys, Dimensions, File, Image, Mimes, Mimetypes | Array shapes, required-offset constraints, or Symfony file objects; fresh inline array and file builders recover their built-in rule semantics at the applicable Laravel version |
| Built-in object rules | Enum | Statically visible enum cases, backing values, and the weakly coerced native values Laravel preserves; literal only/except state is modeled from Laravel 10.46 |
| Version-sensitive | ArrayKeys, Ascii, Base64, Contains, DoesntContain, Encoding, Extensions, HexColor, InArrayKeys, List | ArrayKeys contributes an optional-key shape from Laravel 13.24, including from fresh inline Rule::arrayKeys() builders; Encoding contributes its preserved array, scalar, Stringable, and null union from 12.40; Extensions contributes a Symfony file from 10.34; Contains, DoesntContain, and InArrayKeys contribute array<mixed> from 11.8, 12.22, and 12.16; Base64, HexColor, and List remain mixed before 13.21, 10.33, and 11.0.3; List changes nested projection in 11.23 |
This is not synonymous with complete rule support. For example, Accepted
and Declined contribute exact value unions and required matched paths, while
Array also participates in nested projection behavior.
Laravel’s int and bool aliases are normalized to the canonical Integer
and Boolean rules before type resolution, matching
ValidationRuleParser::normalizeRule() on every supported major.
Allowed-key Array and ArrayKeys rules also interact with List. Inference
retains only the longest permitted consecutive integer prefix beginning at
zero, including the empty-array-only overlap when all allowed keys are
strings. This avoids incorrectly reducing a successful Laravel contract to
never.
Every direct rule has a dedicated static fixture under
tests/rules or tests/version-aware.
Explicitly neutral rules
These 50 names are recognized and deliberately contribute no local value type:
| Family | Rules | Why a neutral contribution is currently conservative |
|---|---|---|
| Configured hasher predicate | CurrentPassword | Delegates to the configured hasher without establishing a native output type; companion rules still constrain the original value |
| Size and comparison | Between, Gt, Gte, Lt, Lte, Max, Min, Size | The accepted native family depends on adjacent numeric, array, string, or file rules and on runtime values |
| Cross-field and domain predicates | AcceptedIf, Confirmed, DeclinedIf, Different, Distinct, DoesntEndWith, DoesntStartWith, EndsWith, Exists, Filled, InArray, NotIn, Password, Same, StartsWith, Unique | These are predicates or environment-dependent checks; several need correlated types to improve safely |
| Flow and output rules | Bail, Exclude, ExcludeIf, ExcludeUnless, ExcludeWith, ExcludeWithout, Missing, MissingIf, MissingUnless, Nullable, Present, PresentIf, PresentUnless, Prohibited, ProhibitedIf, ProhibitedUnless, Prohibits, Required, RequiredIf, RequiredUnless, RequiredWith, RequiredWithAll, RequiredWithout, RequiredWithoutAll, Sometimes | Their primary effect is validation flow, nullability, presence, or projection rather than a standalone native value type |
Neutral does not mean ignored. Required, Present, Missing, Nullable,
Sometimes, and the Exclude* family have separate tree-level handling.
Min also refines a known adjacent string or collection type to its non-empty
form when its parameter is definitely positive; it remains neutral without a
native-family rule because Laravel may instead measure a number or file.
Conditional required and exclusion rules remain conservative because the
output is not represented as a correlated union over the controlling field.
With the default-off experimental option, direct PresentIf, PresentUnless,
MissingIf, and MissingUnless rules can refine output when a required
top-level controller has a finite scalar-literal domain that makes the
condition definitely active or definitely inactive. A domain containing both
outcomes remains conservative. PresentIf and PresentUnless require a
detected Laravel 10.32-or-later version; earlier or unknown versions retain the
conservative shape because those names may have application-defined behavior.
Wildcard expansion adds another projection branch. When an array parent’s
only descendants are below a wildcard, Laravel may expand no nested rules and
preserve the raw parent value. Inference therefore retains blank strings that
bypass array, and for deeper wildcards retains the parent array’s unprojected
keys. A matched unconditional missing descendant may instead project that
parent away, so the combined output key remains optional where necessary.
Laravel performs that rebuild only for a literal, parameterless array rule;
from Laravel 11.23 it also uses a literal list. An allowed-key form such as
array:name preserves its complete permitted parent value even when every
nested rule is missing.
A literal list can retain listness when the first effective wildcard
projection path emits every matched element in input order. Direct scalar
children keep their element type from the rule’s 11.0.3 introduction; required
nested children gain a precise projected element shape when literal-list
reconstruction begins in 11.23. An earlier optional path can make Laravel append
numeric keys out of order even when a later required path eventually emits
every element. Such paths, deeper wildcards, and element exclusions remain
broad when Laravel can produce sparse or reordered integer keys.
Rules currently falling back to mixed
These 8 reserved names have no built-in accepted-value contribution. The fallback is generally sound because it is broad, but it loses useful information and can hide structural guarantees.
| Rules | Introduced | Laravel consequence | Existing runtime evidence | Candidate treatment |
|---|---|---|---|---|
MissingWith, MissingWithAll | 10 | Conditionally constrain whether a path may exist | Fixtures for all supported majors | Correlated optionality for the remaining conditional family |
PresentWith, PresentWithAll | 10 | Conditionally constrain path presence without requiring a non-blank value | Fixtures for all supported majors | Correlated conditional presence |
RequiredIfAccepted, RequiredIfDeclined | 10 / 11 | Conditionally require a field based on another field’s accepted or declined value | Fixtures from introduction onward | Correlated presence unions |
ProhibitedIfAccepted, ProhibitedIfDeclined | 11 | Conditionally restrict a field based on another field | Laravel 11 through 13 fixtures | Correlated optional value domains; prohibition is not equivalent to exclusion |
Enum remains absent from the generated corpus because it requires rule-object
setup that the exporter does not retain. It now has dedicated adversarial
runtime and static coverage instead. Encoding and Extensions also have
focused file coverage, while ArrayKeys is newer than the pinned Laravel 13
fixture but has focused runtime and static coverage.
Object-only built-in predicates
Two Laravel predicates sit outside the 114-name table because they exist only as rule objects:
| Rule object | Introduced | Laravel consequence | Current inference |
|---|---|---|---|
Rule::can() / Can | 10.14 | Calls the authorization gate with the configured ability, arguments, and validated value | Custom predicate with a mixed accepted type; authorization policy supplies no native-value contract |
Rule::anyOf() / AnyOf | 12.8 | Runs alternative nested rule sets and succeeds when at least one complete set passes | Custom predicate with a mixed accepted type; branch state and resulting unions are not extracted |
This treatment is conservative. Can is an application-service predicate and
has no general type contribution to recover. A fresh, statically resolvable
AnyOf could eventually contribute the union of its branches, but that is a
built-in rule-object inference project rather than a parsing rule: Laravel
still preserves the successful branch’s original value.
Presence and output-shape findings
Comparing Laravel’s implicit and dependent rule lists with RuleTreeNode
reveals precision gaps that an accepted-value-only inventory would miss.
| Rule family | Laravel behavior | Current shape | Finding |
|---|---|---|---|
Required | Key must exist and contain a non-empty value | Required key | Modeled |
Accepted, Declined | At a matched path, each calls Laravel’s required check before checking its exact accepted set | Required matched path with an exact value union; zero-match wildcard parents remain optional | Modeled |
Present | A matched path must exist, but blank and null values are not rejected by presence alone | Required matched path with blank-value bypass preserved; zero-match wildcard parents remain optional | Modeled |
Missing | A matched path must not exist | Omitted named path and bare-array missing-only projection; parameterized array parents remain | Modeled |
Exclude | Removes the path from validated output | Omitted key | Modeled |
Conditional Exclude* | May remove the path according to other runtime data | Optional key | Conservative aggregate model; correlation is lost |
| Conditional required, accepted, declined, present, missing, and prohibited rules | Presence or permitted emptiness depends on other fields | Usually an optional broad key; an experimental option resolves definite direct PresentIf, PresentUnless, MissingIf, and MissingUnless outcomes | Conservative but often imprecise; a precise model may require correlated shape unions |
RequiredArrayKeys | Requires named offsets inside a present array, but does not itself project those keys into output | General arrays intersected with required-offset constraints; matching direct child rules become required only when projection guarantees them | Modeled |
Prohibited deserves particular care. It is not an alias for exclusion or
missingness: Laravel can accept a present value when it satisfies Laravel’s
definition of empty, and that value may remain in validated output. It must not
be modeled by simply deleting the key.
Wildcard presence also remains quantified over runtime matches. A present
or required wildcard descendant does not imply that the wildcard collection
has any elements, so presence improvements must preserve the existing
wildcard-boundary behavior.
Nested-projection descriptions assume Laravel’s default factory setting that
excludes unvalidated array keys. Projects that call
includeUnvalidatedArrayKeys() must enable the extension’s matching option;
affected bare array and version-aware list parents then widen so
unmentioned nested keys remain possible.
Built-in rule objects and fluent builders
String rules are only part of Laravel’s public surface. The supported Laravel
majors expose fluent builders through Illuminate\Validation\Rule and
classes under Illuminate\Validation\Rules; the object-only Can and
AnyOf predicates are part of that surface even though they have no reserved
string-rule identity.
Current static extraction treats them in four ways:
- fresh inline
Enum,Rule::in(),Rule::notIn(),Rule::array(), literal-booleanRule::requiredIf(),Rule::excludeIf(), andRule::prohibitedIf(), literal-booleanRule::when()andRule::unless(),Rule::arrayKeys(),Rule::contains(),Rule::doesntContain(),Rule::date(),Rule::dateTime(),Rule::numeric(),Rule::string(),Rule::dimensions(),Rule::file(),Rule::imageFile(),File::types(),File::image(),Rule::exists(), andRule::unique()expressions receive dedicated extraction of their statically visible semantics, as does exact construction of the supported concrete builder classes; - predicate objects implementing Laravel’s rule contracts are treated like
custom predicates and contribute
mixedunless they have an explicit custom contract; Stringablebuilders that do not implement a predicate contract are opaque, making the affected path optional andmixed.- callbacks, macros, and other open-ended runtime programs remain opaque.
| Current extraction | Representative Laravel objects | Consequence |
|---|---|---|
| Dedicated built-in extraction | Enum, Rule::in(), Rule::notIn(), literal-boolean Rule::requiredIf(), Rule::excludeIf(), Rule::prohibitedIf(), Rule::when(), Rule::unless(), Rule::array(), Rule::arrayKeys(), Rule::contains(), Rule::doesntContain(), Rule::date(), Rule::dateTime(), Rule::numeric(), Rule::string(), Rule::dimensions(), Rule::file(), Rule::imageFile(), exact In / NotIn / RequiredIf / ExcludeIf / ProhibitedIf / ArrayRule / ArrayKeys / Contains / DoesntContain / Date / Numeric / StringRule / Dimensions / File / ImageFile construction, Rule::exists(), Rule::unique() | Fresh inline expressions recover statically visible enum, accepted-set, literal branch, presence or projection, allowed-key, array-predicate, date, numeric, string, dimensions, file, image, exclusion, and database-predicate semantics without executing application code; dynamic or unsupported object state stays mixed |
Custom predicate with mixed accepted type | AnyOf, Can, Email, Password, assigned or unsupported File / ImageFile builders | Adjacent built-in string rules survive, but object state and built-in semantics are not recovered |
Opaque Stringable builder | Callback-driven, dynamic, or assigned ExcludeIf / ProhibitedIf / RequiredIf; ExcludeUnless, ProhibitedUnless, RequiredUnless; assigned or unsupported In / NotIn / ArrayRule / ArrayKeys / array-predicate / Date / Numeric / StringRule / Dimensions builders; and assigned or unsupported Exists / Unique chains | The path widens to optional mixed, even when the builder serializes to a supported string rule |
| Opaque runtime program | Dynamic or callback-driven Rule::when() / Rule::unless(), Rule::forEach, NestedRules, macros | Runtime callbacks or macro state provide no generally available static contract |
Built-in builder support remains a separate implementation track. Treating
these objects as arbitrary third-party validators is safe, but needlessly
imprecise for constant builder expressions whose constructor and fluent-call
state are statically available. Fresh Rule::in() / Rule::notIn() calls and
exact In / NotIn construction recover membership predicates. Their direct
constructors accept only arrays before Laravel 10.36; scalar and variadic
forms are recovered from that boundary. Fresh inline Rule::array() / Rule::arrayKeys()
calls and exact ArrayRule / ArrayKeys construction recover the same string
contracts confirmed by focused runtime coverage; assigned objects, subclasses,
and dynamic construction still lose their key state and remain opaque. Fresh
exact Rule::contains() / Rule::doesntContain() calls and
direct Contains / DoesntContain construction recover the built-in array
predicate at their respective Laravel 12.16 and 12.22 boundaries. Assigned
instances remain opaque. Fresh exact Exists and Unique objects contribute
neutral predicates through verified fluent query modifiers; their database
state affects acceptance, not the native type Laravel preserves. Fresh exact
File and ImageFile builders contribute Symfony file types through their verified
fluent size, MIME, extension, encoding, dimension, and additional-rule
constraints. Defaults, callbacks, macros, subclasses, and assigned builders
remain opaque, as do late-bound self, parent, and static forwarding calls.
Fresh exact Dimensions builders contribute the same Symfony file type through
their declared width, height, and ratio constraints. The extended
minRatio(), maxRatio(), and ratioBetween() methods begin at Laravel
11.23; assigned builders, subclasses, callbacks, macros, and dynamic chains
remain opaque.
Fresh exact Date builders recover the preserved date or parameter-sensitive
formatted-date family from their statically visible fluent predicates. Their
comparisons still validate and preserve input rather than parsing it into a
date object. The resolver retains Laravel 11’s distinct parser boundaries:
bare builders begin at 11.40, chains in rule lists at 11.41, and standalone
chains at 11.43.2.
Literal-boolean RequiredIf, ExcludeIf, and ProhibitedIf factories and
exact constructions collapse to their unconditional rule or to Laravel’s
constraint-free empty-rule projection marker. Callback and dynamic conditions
remain opaque.
Literal-boolean Rule::when() expressions select and flatten their statically
resolvable string or array branch. Rule::unless() does the same from Laravel
10.33 after inverting the condition. An empty selected branch retains the
explicit field marker that Laravel uses during nested output projection.
Laravel does not recursively expand conditional wrappers selected by another
wrapper. Dynamic conditions, callback-produced branches, branches containing
executable calls, assigned wrappers, and unpacked arguments remain opaque.
Prioritized work
1. Extend the experimental conditional presence model
The tree can say “the key must exist” without saying “blank values fail,” and
unconditional missing paths are omitted from output. The first experimental
slice now resolves definite PresentIf, PresentUnless, MissingIf, and
MissingUnless outcomes for a finite literal controller. Future work may
extend this to nested paths, multiple conditions, or genuine correlated unions
only where PHPStan can preserve the relationship and runtime evidence keeps it
sound.
2. Complete statically resolvable built-in builders
Recover the string-rule equivalent or direct contract for constant fluent builders where the native output contract is useful and stable. Callback builders must remain opaque when their branch cannot be resolved; date, numeric, string, and file builders now provide the conservative models described above.
What the survey did not find
This inventory did not produce a new example where a successful Laravel output
is rejected by the current inferred type. Most uncovered rules fall back to
mixed or optional shapes, so their immediate cost is lost precision rather
than a newly demonstrated soundness failure.
That statement is deliberately limited. The survey compared rule inventories, implementation branches, and existing evidence; it did not generate an adversarial runtime corpus for every rule and parameter combination. The project’s conformance direction remains the authority: every successful Laravel output exercised by a test must be accepted by the inferred type.
Relevant project evidence
src/Validation/TypeResolver.phpcontains accepted-value and version-aware inference.src/Validation/RuleTreeNode.phpcontains current presence, exclusion, nullability, and wildcard state.src/Validation/RuleSetResolver.phpandsrc/Validation/CustomRuleTypeResolver.phpdetermine how static rule objects are extracted.tests/LaravelInferenceTest.phpchecks generated Laravel outputs against inferred types.tests/Support/InferenceAuditCases.phpdefines the focused cross-version runtime and reverse-precision probes.- Laravel Version Inference Audit documents the version-profile methodology and known release boundaries.
Testing and runtime verification
This project models Laravel’s behavior rather than the behavior suggested by a rule name. A change to inference therefore needs two independent pieces of evidence: Laravel must actually produce the values being modeled, and PHPStan must emit a type that contains those values.
The complete normal validation suite is exposed through Nix:
nix flake check --keep-going -L
Focused test and audit commands still use PHP and Composer directly. This keeps individual regressions easy to reproduce without making contributors translate ordinary PHPUnit options into Nix expressions.
composer cs also uses Akashi to format-check every inline PHP fence in the
README, changelog, and docs/. Run that check alone with
composer docs:format. Apply safe atomic corrections with
composer docs:format:fix; composer cs:fix formats both PHP source files and
the documentation fences. This dogfooding integration checks maintained
examples for PHP formatting; it does not execute illustrative fragments or
PHPStan-only examples as though they were standalone runtime programs.
Build and link-check the mdBook with composer docs:check. See
Development.
Choose the smallest useful test
| Question | Test layer | Typical location |
|---|---|---|
| Does the parser or resolver build the intended type? | Fast unit test | tests/RuleTreeNodeTest.php, tests/TypeResolverTest.php |
| What does Laravel accept and return? | Focused runtime test with named cases | tests/*LaravelRuntimeTest.php |
| What type does PHPStan show at a real call site? | Explicit assertType() fixture | tests/rules, tests/structure, tests/version-aware |
| Is behavior stable across supported Laravel releases? | Deterministic inference audit | tests/Support/InferenceAuditCases.php, tests/fixtures/version-audit |
| Do all named bounded combinations remain sound? | Exhaustive property catalogs | tests/Property/InferenceSoundnessPropertyTest.php |
| Does the whole extension still work? | Full PHPUnit and PHPStan suites | composer exec phpunit, composer exec phpstan analyse |
Start with the narrowest layer that reproduces the behavior, but do not use a resolver-only assertion as evidence of Laravel’s runtime contract. Changes to inferred behavior normally need a focused Laravel runtime case and a static inference assertion as well as the resolver unit test.
Adding or changing inference
- Give the runtime scenario a descriptive case name. The focused runtime suites use named providers and include the case name, rules, and input in a failure.
- Reproduce the behavior against every supported Laravel major. If a patch boundary is suspected, test both sides of it.
- Add or update the parser/resolver unit assertion.
- Add an explicit PHPStan fixture assertion at the relevant entry point.
- Add a deterministic audit case when the scenario is adversarial, version-sensitive, or useful as a long-term runtime witness.
- Regenerate complete audit baselines only after reviewing the runtime diff and upstream source reference.
Never narrow an expected type merely because a rule name appears to promise that type. Preserve conservative inference when Laravel behavior or a custom runtime contract cannot be established.
Focused runtime cases
AssertsLaravelValidation
runs a named case through Laravel, checks the exact validated() output, and
checks that the inferred PHPStan type contains it. For example, presence and
projection cases live in
PresenceLaravelRuntimeTest rather
than in the large historical export test.
Keep providers grouped by one behavior and name cases for the distinction they
prove, such as present array blank bypass with zero wildcard matches. A
failure should be understandable without converting an opaque numeric index
back into several generator tables.
Run a focused file or case with ordinary PHPUnit options:
vendor/bin/phpunit tests/PresenceLaravelRuntimeTest.php
vendor/bin/phpunit --filter 'present array blank bypass'
For a broad local loop without the child-process result-cache and CLI tests, run:
composer test:fast
The authoritative suite still includes the subprocess group. Use the fast
loop while iterating on unrelated code, then run the complete Nix checks before
considering a change ready.
The FormRequest result-cache scenarios launch PHPStan repeatedly to verify cold, warm, selective, and global invalidation behavior. Run that focused suite with:
composer test:form-request-cache
Nix exposes it as the independent form-request-result-cache check. The
ordinary PHP and Laravel compatibility jobs exclude this group; the complete
suite runs with the canonical dependency closure and again in the
minimum-PHPStan consumer check.
Static inference fixtures
PHPStan fixtures remain deliberately explicit. A contributor should be able to read the rules and the expected type next to each other:
assertType('array{value?: string}', Validator::make($input, [
'value' => 'string',
])->validated());
Do not generate these assertions from the resolver. They are an independent
check that the extension is wired into PHPStan correctly. Generated upstream
and audit fixtures are identified separately in .gitattributes and their
directories contain regeneration instructions.
Named property catalogs
InferencePropertyCases builds
three finite, named catalogs for scalar, structural, and conditional behavior.
The property suite visits every catalog entry. Failures print a stable semantic
ID such as boolean.filled.numeric-string-zero.rule-first, along with the
rules, input, Laravel version, inferred type, and actual output type.
Run the complete bounded catalog with:
composer test:property
The catalog integrity test locks the intended sizes and requires unique, descriptive IDs. A catalog failure is discovery evidence, not the permanent regression: promote it into a named focused runtime test or the deterministic audit.
Deterministic audit cases
List the available semantic case IDs and profiles without booting PHPStan or running Laravel:
php scripts/inference-audit.php --list-cases
php scripts/inference-audit.php --list-profiles
Run one or more cases against the installed Laravel release and a committed baseline:
php scripts/inference-audit.php \
--baseline=10-latest \
--case=present.value \
--case=missing.absent
Case filters are repeatable and exact. They are intentionally incompatible
with --update: a snapshot update must always regenerate the complete case
map, so a focused command cannot silently erase or leave stale evidence.
Portable cross-version matrix
The matrix runner creates isolated Composer projects under
tmp/version-audit/<profile>. It never changes the root manifest, lockfile, or
installed dependencies. Run every profile with:
composer test:audit:matrix
The current PHP binary must satisfy every selected profile; running all profiles currently requires PHP 8.3 or newer. Select one or more profiles when using an older PHP or investigating a boundary:
composer test:audit:matrix -- --profile=11.22.0 --profile=11.23.0
Exact profiles reuse a matching cached install. Floating *-latest profiles
always run composer update. Their committed version and source reference
record the last reviewed snapshot, while ordinary checks compare the current
release’s case results rather than failing solely because a behaviorally
identical patch was published. Exact profiles continue to require matching
version and source provenance. Use --reinstall to discard selected caches,
--composer=/path/to/composer to select a Composer executable, and --update
to regenerate the selected complete baselines:
composer test:audit:matrix -- --profile=12.22.0 --reinstall
composer test:audit:matrix -- --profile=12.22.0 --update
Some deliberately pinned historical Laravel releases have known security advisories. The runner disables dependency-policy blocking solely inside these disposable audit projects and disables Composer plugins and scripts. Do not use the generated projects as application dependencies.
Each snapshot records the installed Laravel version and the actual 40-character source reference reported by Composer. Exact-profile checks verify both before comparing runtime cases. Floating profiles retain that provenance as the last reviewed reference but fail only when the observed case results change, so a new patch release remains visible without making every unchanged release a CI failure.
Nix profile-shell convenience
If Nix is available, the wrapper selects the minimum compatible project shell for each profile and delegates to the same portable matrix runner:
composer test:audit:matrix:nix
composer test:audit:matrix:nix -- --profile=10.0.0 --profile=13-latest
The Nix wrapper contains no audit or snapshot logic. Contributors using local
PHP binaries, containers, phpenv, or another version manager exercise the
same canonical PHP implementation.
Before submitting an inference change
Run focused tests while developing, then run the complete normal suite:
nix flake check --keep-going -L
The equivalent focused Composer commands remain useful when diagnosing an individual layer:
composer exec phpunit
composer exec phpstan analyse
composer cs
composer validate --strict
Run the relevant audit profiles whenever a claim depends on a Laravel version.
Mutation testing is valuable for deterministic inference branches but is not a
replacement for runtime evidence. Its separate setup and subprocess exclusions
are described in Development. The Infection configuration
also narrowly ignores mutations that make RuleTreeNode::resolvePath() recurse
without consuming input: those mutants can exhaust PHP’s native stack before
Infection’s timeout can stop the process. The documentation group is excluded
because it asserts copied mdBook assets against the root Doctrine pin, not
extension source.
Mutation testing is an explicit Nix package rather than a flake check:
nix build -L .#mutation
The explicit package builds five source shards independently, each with the
configured four Infection workers, and enforces the project-wide MSI,
covered-MSI, timeout, and expected-ignore behavior. GitHub adds those shard
derivations to the exhaustive Nix matrix as separate jobs, then applies the
same aggregate checks to their uploaded reports. Each runner therefore uses at
most four Infection workers while the five shards can progress in parallel.
Ordinary nix flake check never runs mutation testing.
The ordinary checks do include infection-harness: filesystem conformance and
the FormRequest source-discovery tests with Infection intercepting unchanged
source. This detects harness failures that could otherwise count as killed
mutants. The isolated Infection Composer install applies a small compatibility
patch for include-interceptor 1.0.0: link metadata must remain available when
the target is missing, and quiet metadata queries must not emit warnings.
The patch accepts only the pinned original or already-patched source. Review
or remove it when updating the interceptor; do not bypass a checksum failure.
Run these checks locally after installing the toolchain:
composer --working-dir=tools/infection install
composer --working-dir=tools/infection test
php vendor/bin/phpunit --bootstrap tools/infection/tests/bootstrap.php \
tests/FormRequestSourceDiscoveryTest.php --no-coverage
Laravel-version inference audit
Result
No successful output in the portable audit corpus falls outside the inferred type.
This audit checks whether Laravel’s validation behavior changes across the
releases supported by phpstan-laravel-validation, and whether the
extension’s version-aware inferred types contain every successful output
observed at those releases.
This is an audit result, not a proof of universal soundness. It covers the portable rule families and interactions listed below. Files, databases, DNS, password services, image metadata, and application-defined validation extensions remain outside the deterministic corpus.
What was audited
The audit pins the first release of every supported major, the current latest release, and both sides of every semantic transition used by version-aware inference. Profiles and recorded commits are in Audited releases.
User-facing inference boundaries are summarized in Laravel Version Behavior.
Important version boundaries
| Boundary | Source | Effect |
|---|---|---|
| Laravel 12.0 | Portable corpus | Top-level literal integer rule keys are preserved instead of reindexed from 0 |
| Laravel 12.22 | Portable corpus | integer:strict requires a native integer |
| Laravel 12.35 | Portable corpus | digits rejects booleans, null, and Stringable objects |
| Laravel 13.4 | Portable corpus | ascii requires a native string |
| Laravel 13.4 | Portable corpus | min_digits and max_digits reject booleans, null, and Stringable objects |
| Laravel 13.6 | Portable corpus | digits_between rejects booleans, null, and Stringable objects |
| 10.21.1 | Builder fixtures | In / NotIn serialize enum cases |
| 10.32 | Runtime suite | present_if / present_unless exist |
| 10.33 | Runtime suite | hex_color; Rule::unless() |
| 10.34 | Runtime suite | extensions |
| 10.36 | Builder fixtures | In / NotIn constructors accept scalar, variadic, and Arrayable inputs |
| 10.46 | Builder fixtures | Enum::only() / Enum::except() |
| 11.0 | Runtime suite | Laravel 10 trims password fields; 11+ does not |
| 11.0.3 | Runtime suite | list; required_if_declined |
| 11.7 | Builder fixtures | Rule::array() |
| 11.23 | Runtime suite | Literal list joins nested reconstruction; Dimensions ratio methods |
| 11.40–11.43.2 | Builder fixtures | Fluent Date builder, then list and standalone expansion |
| 11.42 | Builder fixtures | Fluent Numeric builder |
| 12.16 | Runtime suite | in_array_keys; Rule::contains() |
| 12.22 | Runtime suite | doesnt_contain; Rule::doesntContain() |
| 12.40 | Runtime suite | encoding |
| 12.44 | Builder fixtures | Rule::dateTime() and now-relative date predicates |
| 12.55 | Builder fixtures | Numeric::integer(strict: true); Rule::string() |
| 13.4 | Runtime suite | hex_color rejects compatible Stringable objects |
| 13.21 | Runtime suite | Native-string-only base64 |
| 13.24 | Runtime suite | array_keys; Rule::arrayKeys() |
Where uncertainty remains
The portable corpus does not execute environment-dependent rules. Builder
introduction boundaries outside that corpus are pinned by upstream commits,
focused fixtures, and cross-profile PHPUnit. Floating *-latest profiles
fail only when observed case results change.
Builder-boundary evidence
Laravel 11.7 adds the Rule::array() builder via
8c684a222143.
Laravel 11.40 adds the fluent Date builder via
1049c0370b24,
but Laravel’s validation parser did not expand its pipe-delimited chains
inside rule lists until 11.41 via
b7fca4b8fe48,
or as standalone field rules until 11.43.2 via
1f5e3833ae2b.
Laravel 12.44 adds Rule::dateTime() plus the builder’s now-relative
predicates via
00ed6626514a.
Laravel 12.3 had already changed Date::format() from a date|date_format
intersection to a single date_format constraint via
726434c6d8b3;
both forms require the same sound native output family.
Laravel 11.42 adds the fluent Numeric builder via
75b6392fd7c8,
and Laravel 12.55 adds its strict integer option via
73b393274b25.
Laravel 12.55 also adds the fluent StringRule builder via
36c2a3a7d317.
Laravel 12.16 adds Rule::contains() and Contains via
3a9fa0214fc3,
while Laravel 12.22 adds Rule::doesntContain() and DoesntContain with
the underlying rule via
ad138584ef0b.
Laravel’s Enum rule adds literal only/except filters in 10.46 via
8d47be393e43.
Laravel 10.21.1 also teaches the In and NotIn builders to serialize
enum cases via
4989e6de0766.
Laravel 10.36 expands their concrete constructors from array-only inputs to
the factory’s scalar, variadic, and Arrayable forms via
aeb284959f15.
The extension obtains one analyzed-project Laravel version from the
matching Composer installed-package dataset, falling back to
composer.lock when runtime package data for that project root is
unavailable. It passes that version through every inference entry point,
retains the broad historical behavior before each boundary, and narrows
the type after it. Missing, malformed, and unsupported versions remain
conservative rather than silently inheriting a version from an unrelated
Composer root loaded into PHPStan.
Audited releases
| Profile | Constraint | PHP floor | Recorded release | Upstream commit |
|---|---|---|---|---|
10.0.0 | 10.0.0 | 8.1 | 10.0.0 | be2ddb5c31b0 |
10.32.1 | 10.32.1 | 8.1 | 10.32.1 | b30e44f20d24 |
10.33.0 | 10.33.0 | 8.1 | 10.33.0 | 4536872e3e5b |
10.34.0 | 10.34.0 | 8.1 | 10.34.0 | 92b78fdd1f38 |
10-latest | ^10.0 | 8.1 | 10.50.3 | 74e222cee687 |
11.0.0 | 11.0.0 | 8.2 | 11.0.0 | 6089f679d6d2 |
11.22.0 | 11.22.0 | 8.2 | 11.22.0 | 868c75beacc4 |
11.23.0 | 11.23.0 | 8.2 | 11.23.0 | 576f6f5d63f6 |
11-latest | ^11.0 | 8.2 | 11.55.1 | 8d786e25c5fb |
12.0.0 | 12.0.0 | 8.2 | 12.0.0 | bd8aeb64d3f9 |
12.21.0 | 12.21.0 | 8.2 | 12.21.0 | ac8c4e73bf1b |
12.22.0 | 12.22.0 | 8.2 | 12.22.0 | 6ab00c913ef6 |
12.34.0 | 12.34.0 | 8.2 | 12.34.0 | f9ec5a5d88bc |
12.35.0 | 12.35.0 | 8.2 | 12.35.0 | 9583ef9e405a |
12.39.0 | 12.39.0 | 8.2 | 12.39.0 | 1a6176129ef2 |
12.40.0 | 12.40.0 | 8.2 | 12.40.0 | 3159215d904a |
12-latest | ^12.0 | 8.2 | 12.66.0 | 82a53323c701 |
13.0.0 | 13.0.0 | 8.3 | 13.0.0 | 3e33f431a053 |
13.3.0 | 13.3.0 | 8.3 | 13.3.0 | 118b7063c44a |
13.4.0 | 13.4.0 | 8.3 | 13.4.0 | 912de244f88a |
13.5.0 | 13.5.0 | 8.3 | 13.5.0 | ffa1850049a6 |
13.6.0 | 13.6.0 | 8.3 | 13.6.0 | 416a93ea9c53 |
13.20.0 | 13.20.0 | 8.3 | 13.20.0 | b9d1bccad5fb |
13.21.0 | 13.21.0 | 8.3 | 13.21.0 | d1e02ce7b7e2 |
13.23.0 | 13.23.0 | 8.3 | 13.23.0 | 92a707229148 |
13.24.0 | 13.24.0 | 8.3 | 13.24.0 | 6d481710375d |
13-latest | ^13.0 | 8.3 | 13.25.0 | ed36fe882bd4 |
The *-latest constraints intentionally float in CI. Their committed
baselines record the releases above. A later patch release that changes any
probed contract fails the baseline test and requires an explicit review rather
than silently inheriting the old inference assumption.
The runner identifies the installed release through Composer package metadata,
not Application::VERSION. Laravel’s v12.22.0 package still contains the
stale application constant 12.21.0; using that constant would mislabel the
exact release on the strict-integer boundary.
Method
InferenceAuditCases defines one
deterministic input and rule set for each adversarial probe. For every case,
InferenceAudit:
- runs the rule through Laravel’s own
Validation\Factory; - records whether validation failed, threw, or returned validated output;
- converts successful output into a PHPStan type;
- resolves the same rule with this extension and the exact installed Laravel version; and
- records whether the inferred type is a supertype of Laravel’s actual output.
The audit uses PHPStan’s isSuperTypeOf() relation for this containment check.
Its accepts() relation also models PHP parameter coercions, so it can report
that float accepts an int even though an inferred float does not literally
describe an integer runtime value. That distinction matters in both directions
of this audit.
The committed JSON files under
tests/fixtures/version-audit are runtime
contract snapshots, not hand-authored expected types. The
inference-audit.php runner can load an
isolated Composer installation of Laravel before the project’s own autoloader,
which lets the same extension build be checked against exact framework
releases.
The runner deliberately normalizes objects, resources, non-finite floats, and the array-to-string warning into stable data. Unrelated PHP engine and dependency deprecations are not part of the Laravel validation contract and are omitted from the snapshot.
An additional property suite exhaustively visits three finite named catalogs: 1,620 scalar-presence and native-representation cases, 180 nested-projection and wildcard cases, and 280 cross-field presence and exclusion cases. Each catalog requires at least 30 percent of its cases to produce successful Laravel output so a mostly rejected domain cannot pass vacuously. Every successful case runs the same runtime-to-static containment check without creating a snapshot. Exhausting these bounded catalogs strengthens the observed evidence; it does not prove universal soundness outside them.
Inventory
| Area | Representative probes | Result |
|---|---|---|
| Accepted and declined values | accepted.true, accepted_if.inactive, declined.false, declined_if.inactive | No observed release difference |
| Boolean and numeric predicates | boolean.*, integer.*, numeric.*, decimal, multiple_of, and fresh fluent numeric builders | integer:strict begins at 12.22; the exact 11.42 and 12.55 builder cutovers are pinned by the linked upstream commits, tag history, and focused static fixtures, while cross-profile PHPUnit confirms representative behavior before and after them |
| Digit-count predicates | digits.*, digits_between.*, max_digits.*, and min_digits.*, including booleans, null, and Stringable objects | Native-type guards begin at 12.35 for digits, 13.4 for min_digits and max_digits, and 13.6 for digits_between |
| Text predicates | alpha*, ascii.*, string, lowercase, uppercase, regex, not_regex, and fresh fluent string builders | ascii boundary at 13.4; the exact 12.55 StringRule cutover is pinned by the linked upstream commit, tag history, and focused static fixtures, while cross-profile PHPUnit confirms representative behavior before and after it |
| Hex colors | valid strings, compatible Stringable, optional blank input, and unsupported-rule behavior | Rule introduction at 10.33; native-string boundary at 13.4, covered by the cross-profile PHPUnit suite |
| File extensions | valid and failed uploads, a compatible Symfony file subclass, invalid native values, optional blank input, and unsupported-rule behavior | extensions begins at Laravel 10.34, covered by the cross-profile PHPUnit suite rather than the portable audit corpus |
| Character encoding | strings, arrays, scalars, Stringable, null, valid and invalid file contents, invalid uploads and parameters, and unsupported-rule behavior | encoding begins at Laravel 12.40, covered by the cross-profile PHPUnit suite rather than the portable audit corpus |
| JSON, dates, and membership | json.*, date*, comparisons, fresh fluent date builders, scalar in, and fresh Rule::in() / Rule::notIn() builders and exact constructors | Scalar behavior is stable; the date builder begins in 11.40, chains become usable in rule lists at 11.41 and standalone at 11.43.2, and dateTime plus now-relative predicates arrive in 12.44; enum-valued membership builders begin in 10.21.1, while scalar and variadic direct constructors begin in 10.36. Builder boundaries are pinned by upstream commits, tag history, focused static fixtures, manual cross-profile runtime probes, and cross-profile PHPUnit |
| Network and identifiers | email, ip, ipv4, ipv6, mac_address, timezone, url, uuid, ulid | No observed release difference |
| Arrays and projection | bare and keyed arrays, parameterized-parent preservation, required array offsets, numeric rule keys, nested child projection, wildcards, parent-plus-child rules, and fresh Rule::array() builders | Numeric rule-key boundary at Laravel 12; Rule::array() begins at Laravel 11.7 and list reconstruction changes at Laravel 11.23, covered by the cross-profile PHPUnit suite |
| Array-only predicates | required and optional values, non-array rejection, preserved associative and nested arrays | contains, in_array_keys, and doesnt_contain begin at Laravel 11.8, 12.16, and 12.22, covered by the cross-profile PHPUnit suite rather than the portable audit corpus |
| Allowed array keys | permitted subsets, extra-key rejection, numeric keys, empty parameters, blank bypass, nested rules, and the fluent builder | array_keys begins at Laravel 13.24, covered by the cross-profile PHPUnit suite rather than the portable audit corpus |
| Enum objects | pure, string-backed, and integer-backed cases; weakly coerced preserved values; optional blanks; and literal filters | Base behavior is stable across Laravel 10–13; only and except begin in 10.46, covered by the cross-profile PHPUnit suite rather than the portable audit corpus |
| Image dimensions | a real one-pixel image file, incorrect dimensions, native path strings, optional blanks, nullable input, and fresh Dimensions builders | No difference observed for the native value family; the extended ratio builder methods begin at Laravel 11.23 and are covered by exact-version static fixtures and the cross-profile PHPUnit suite |
| Presence and conditions | optional blanks, nullable, present, missing, zero-match wildcard parent preservation, confirmed, required_if, exclude_if, and literal-boolean RequiredIf / ExcludeIf / ProhibitedIf builders | No observed release difference; the builders’ true rules and false empty-rule projection markers are covered by cross-profile PHPUnit |
| Default HTTP middleware | password-path trimming before validation | Laravel 10 versus 11+ boundary covered by the cross-profile PHPUnit suite |
| Static entry points | facade, factory, request, controller, helper, and validator unions | Covered by the existing PHPStan fixture suite |
| Environment-dependent behavior | other file and image metadata, database, DNS, password-rule service checks, custom rules | Catalogued but not executed by this portable audit |
The inventory focuses on rules for which the extension currently narrows a
type, plus representative non-narrowing and structural rules that can change
presence or projection. It is intentionally adversarial: values such as
integral floats, booleans, Stringable objects, resources, blank strings,
missing wildcard parents, and undeclared nested keys are included because
ordinary happy-path strings do not reveal Laravel’s native output contract.
Findings
Digit-count predicates add native-type guards at different releases
Before its guard, each digit-count predicate accepts and preserves true and
a Stringable object rendering '1' under a suitable positive digit bound.
Zero-length bounds can also admit false and null: digits:0,
digits_between:0,3, min_digits:0, and max_digits:3 all demonstrate this.
required|max_digits:3 even preserves false.
Laravel added guards to digits in 12.35 via
81331846d04b,
to min_digits and max_digits in 13.4 via
be799ce272d4,
and to digits_between in 13.6 via
7f3aa1ad2348.
The pinned profiles immediately before and after each release confirm the
change. The guards reject booleans, null, and objects; accepted integers,
integral floats, and digit strings still retain their native representation.
Optional blank strings and nullable null values can bypass the predicates.
The conservative inferred family is
bool|float|int|numeric-string|Stringable|null before each guard, and when the
Laravel version is unknown or unsupported. After the guard it is
float|int|numeric-string. The historical family does not refine booleans or
null by digit bounds. Adding numeric or string still intersects the family
with that companion rule’s type.
Sixteen portable cases cover the newly recognized native values across every
audit profile. LaravelInferenceTest::testDigitCountInferenceContainsNativeLaravelOutput
also checks native identity and inferred containment, with rejected-input,
nullable, blank, and numeric-intersection controls. Static call-site fixtures
and TypeResolverTest::testVersionAwareDigitCountInference cover the inferred
families and exact version boundaries.
Laravel 12 preserves top-level numeric rule keys
Laravel 10 and 11 pass parsed rules through array_merge_recursive() when
adding them to the validator. PHP reindexes numeric keys during that merge, so
this apparently literal rule path:
[
3 => 'required|string',
]
actually validates and returns key 0. Multiple sparse keys such as 3 and
5 become 0 and 1 in encounter order. Negative integer keys are reindexed
the same way. Laravel 12 replaced that merge with per-key assignment in
83e28d065b7b,
so Laravel 12 and 13 preserve the original integer keys.
The corresponding sound types are therefore version-dependent:
// Laravel 10 and 11
array{string}
// Laravel 12 and 13
array{3: string}
This applies only to literal integer keys in the top-level rule map. Numeric
segments in string paths such as items.3.name remain literal path segments
on every supported release. When the Laravel version is unavailable or
unsupported, the extension uses a general array shape rather than guessing
which output key Laravel will produce.
Laravel 12.22 changes integer:strict
Laravel 12.21 accepts and preserves both '1' and 1.0 for this rule:
['value' => 'required|integer:strict']
Before Laravel 12.22, the parameter is ignored and validation has the same
coercive behavior as the ordinary integer rule. Laravel 12.22 adds strict
mode and rejects both non-integer values. Native int values continue to pass
and are preserved.
For Laravel 10 through 12.21, the inferred value type remains:
float|int|numeric-string|Stringable|true
That union is required for Laravel 10, 11, and 12.0 through 12.21. From Laravel
12.22 through the supported 13.x releases, the extension now infers int. If
the analyzed version is unavailable or outside the supported range, it keeps
the union.
Laravel 13.4 changes ascii
Laravel 13.3 retains the coercive behavior inherited from Laravel 10 through
12. The ascii predicate casts values to strings and validated() preserves
the originals. The audit reproduces successful integer, boolean, null,
Stringable, resource, and warning-tolerant array outputs.
Laravel 13.4 adds a native is_string() guard and rejects every one of those
non-string inputs. The behavior remains string-only through the pinned Laravel
13.23 release.
For Laravel 10 through 13.3, the inferred value type remains:
array|bool|float|int|resource|string|Stringable|null
For Laravel 13.4 through the supported 13.x releases, the extension now infers
string before applying presence and blank-value behavior. The broad union is
not an invented analyzer edge case on older versions; it is the set of native
categories Laravel can successfully return. It remains the safe fallback when
version context is unavailable.
hex_color has two release boundaries
Laravel 10.32.1 has no validateHexColor() method. A non-blank value reaches
Laravel’s missing validator method and throws BadMethodCallException, while
an optional blank string can bypass the unknown non-implicit rule and remain
in validated(). Applications on those releases may also register their own
rule under the same name. The extension therefore retains mixed before
Laravel 10.33 rather than inventing a contract for an absent built-in rule.
Laravel 10.33 adds this implementation:
return preg_match('/^#(?:(?:[0-9a-f]{3}){1,2}|(?:[0-9a-f]{4}){1,2})$/i', $value) === 1;
The weak internal string conversion accepts a compatible Stringable object,
and validated() preserves that object instead of returning its string form.
Laravel retains this behavior through 13.3, so a required field needs the
following structural type:
non-empty-string|Stringable
Laravel 13.4 adds an is_string() guard. From that release onward, the sound
required value type narrows to non-empty-string. Optional raw validator input
still includes blank strings because Laravel skips this non-implicit rule for
blank values; HTTP normalization can remove that branch when enabled in the
extension.
extensions begins in Laravel 10.34
Laravel 10.33 and earlier have no validateExtensions() method. A non-blank
value therefore reaches the missing validator method unless the application
registers a custom rule with that name. Inference remains mixed before
Laravel 10.34 rather than assigning the later built-in contract to an
open-ended extension point.
Laravel 10.34 adds a file predicate built around this sequence:
if (! $this->isValidFileInstance($value)) {
return false;
}
return in_array(strtolower($value->getClientOriginalExtension()), $parameters);
The method also has a family-wide PHP-upload block for php, php3 through
php8, phtml, and phar. That block is disabled only when the literal php
parameter appears anywhere in the rule; the ordinary extension allow-list is
then still applied. Consequently, extensions:phtml rejects an upload named
evil.phtml, while extensions:phtml,php accepts it. For an UploadedFile,
the block inspects the client-supplied extension. For another Symfony File,
it inspects the physical path extension instead.
Successful validation preserves the original object. Laravel’s initial guard
establishes only that it is a Symfony File; it does not establish that the
value is specifically an UploadedFile. A compatible File subclass that
supplies getClientOriginalExtension() can pass, while a plain File with a
non-PHP path reaches an undefined-method error. The sound useful type is
therefore Symfony File, not the narrower UploadedFile.
Focused probes cover a valid upload, a compatible file subclass, the
plain-file error path, the PHP-family block and its php escape hatch, failed
uploads, mismatched and case-sensitive parameters, absent parameters, invalid
native values, optional blank bypass, and preservation in validated().
Before 10.34 and when version context is unavailable, the type remains
mixed. The rule was introduced by
4ae1ef68e4e4.
encoding begins in Laravel 12.40
Laravel 12.39 and every earlier supported release have no
validateEncoding() method. Non-blank use therefore reaches Laravel’s missing
validator method unless an application has registered its own rule under that
name. The extension retains mixed before Laravel 12.40 rather than assigning
the later built-in contract to that open extension point.
Laravel 12.40 adds a rule that first verifies the requested encoding name and then delegates to PHP:
return mb_check_encoding(
$value instanceof File ? $value->getContent() : $value,
$parameters[0],
);
The apparent text predicate consequently has a much broader native output
contract. PHP’s weak parameter coercion accepts booleans, integers, floats,
and compatible Stringable objects in addition to strings. Arrays are passed
through directly, while Symfony File objects are checked through their
contents. Laravel preserves the original input after success rather than the
coerced string, array elements, or file content that PHP actually inspected.
An explicit null also succeeds and remains in validated(), although current
supported PHP releases emit the deprecation associated with
mb_check_encoding(null, ...). Resources and arbitrary non-stringable objects
cannot be passed to the native function. Array validity depends recursively on
its contents, so the useful sound array branch remains array<mixed> rather
than claiming a narrower element type.
The resulting structural value type is:
array<mixed>|bool|float|int|string|Stringable|null
Symfony File is contained by the Stringable branch, even though Laravel
checks file contents rather than the object’s string path. Laravel also marks
encoding as a file rule, so a failed UploadedFile is rejected before its
contents are inspected. Missing parameters and unknown encoding names throw
InvalidArgumentException; optional blank strings can still bypass this
non-implicit rule.
Focused probes cover the successful native categories, valid and invalid file
contents, failed uploads, invalid arrays, excluded object categories,
parameter errors, blank bypass, and preservation in validated(). The exact
12.39 and 12.40 profiles lock the introduction boundary, and the implementation
is unchanged through the pinned Laravel 13 release. The rule was introduced by
660c653024d0.
base64 begins in Laravel 13.21
Laravel 13.20 and every earlier supported release have no
validateBase64() method. Non-blank input therefore reaches Laravel’s missing
validator method and throws BadMethodCallException, although optional blank
strings and nullable null values bypass the unknown non-implicit rule and can
still remain in validated(). Because applications may register a custom rule
under the absent built-in name, the extension retains mixed through 13.20.
Laravel 13.21 adds an explicitly native-string implementation:
if (! is_string($value) || $value === '') {
return false;
}
$decoded = base64_decode($value, true);
return $decoded !== false && base64_encode($decoded) === $value;
Runtime probes against Laravel 10.0, 11.0, 12.0, 13.0, 13.20, and 13.21
confirm the boundary. From 13.21 onward, successful non-blank values are
preserved native strings, while integers, floats, booleans, arrays, and
compatible Stringable objects fail. The sound required value type is
therefore non-empty-string; optional raw validator input still includes the
blank-string bypass.
The Rule::array() builder begins in Laravel 11.7
Laravel’s underlying array string rule predates every supported release, but
the Rule::array() factory and its ArrayRule object do not. They were added
in Laravel 11.7 by
8c684a222143.
Before that release, an application could still provide a macro under the same
method name, so analysis cannot assume Laravel’s later builder contract.
The builder preserves a distinction that matters to validated() projection.
Both Rule::array() and Rule::array([]) serialize to the bare array rule,
which lets nested child rules reconstruct the returned parent. A non-empty key
list serializes to a parameterized rule such as array:name,email; Laravel
then preserves the complete permitted parent rather than rebuilding it solely
from validated descendants. The builder therefore affects output shape, not
only the accepted value family.
Omitting the argument is also observably different from passing null.
Laravel’s factory forwards the actual argument list through func_get_args():
no argument produces bare array, while explicit null produces array: and
permits only the empty-string key. Focused runtime coverage checks these forms,
scalar and enum keys, extra-key rejection, and nested projection across the CI
Laravel profiles.
The serialization is lossy for some key strings. ArrayRule joins keys with
unquoted commas, and Laravel then parses the resulting rule parameters as CSV.
For example, Rule::array(['a,b']) becomes array:a,b and permits a and b,
not a literal a,b key. The expression resolver reproduces that round trip
rather than assigning the builder’s pre-serialization key list a prettier but
false meaning. Fresh constant factory calls and exact ArrayRule construction
are recovered from 11.7 onward; assigned objects, subclasses, dynamic
construction, dynamic arguments, and earlier versions stay broad. Float keys
also stay broad because PHP’s configurable runtime precision can change the
serialized key after analysis.
array_keys begins in Laravel 13.24
Laravel 13.23 and every earlier supported release have no
validateArrayKeys() method. As with other absent built-in names, non-blank
use either reaches Laravel’s missing validator method or an application-defined
rule registered under that name. Inference therefore remains mixed before
13.24.
Laravel 13.24 adds a predicate that requires a native array and rejects keys outside the rule parameters. It does not require any listed key to exist, so this rule:
['value' => 'required|array_keys:name,email']
accepts the empty array and either permitted subset. Laravel preserves the original array, including its values, so the corresponding structural type is:
array{name?: mixed, email?: mixed}
The focused runtime test also confirms that canonical numeric key parameters
such as 0 use integer offsets, while a non-canonical numeric-looking key such
as 01 remains a string offset. Optional blank strings bypass the
non-implicit rule as usual. Nested child rules do not turn array_keys into a
parent-reconstruction rule: the complete permitted parent remains in
validated().
Combining an allowed-key rule with list produces another non-obvious
intersection. A list can use only consecutive integer keys beginning at zero.
array_keys:name|list therefore accepts only the empty array, while
array_keys:0,2|list accepts the empty array and a one-element list at key
zero. The resolver models the longest permitted consecutive prefix directly;
otherwise PHPStan can collapse the real empty-array overlap between an
optional-key shape and list to never. The same repair applies to the
pre-existing array:name|list form.
The two empty-looking spellings are observably different. Bare array_keys
throws InvalidArgumentException when it is evaluated because the rule
requires a parameter. array_keys: supplies one empty parameter and permits
only the empty-string array key. The extension models both contracts from
13.24 and remains broad when the Laravel version is unavailable or unsupported.
The rule was introduced by
91eee4b8a7c4.
Rule::arrayKeys() and direct ArrayKeys construction serialize to the same
string contract at runtime, including the empty-key-list case. Fresh exact
expressions with statically visible scalar or enum keys recover that contract
directly. Assigned objects, subclasses, dynamic construction, and dynamic or
Arrayable arguments still lose the builder’s key state and remain opaque.
Float keys likewise stay opaque because runtime precision can change their
serialized spelling.
Three array predicates have mid-major introductions
Laravel adds contains in 11.8, in_array_keys in 12.16, and
doesnt_contain in 12.22. The corresponding changes are
4815757851f0,
8b9f434868d1,
and
ad138584ef0b.
Before each release, non-blank use reaches Laravel’s missing validator method
unless the application has registered a custom rule with the same name.
Inference therefore remains mixed before the built-in contract exists.
All three built-in methods first require is_array($value). They differ in
which members or keys make that array pass, but successful validation retains
the original array and its arbitrary keys and values. A required field can
therefore narrow to array<mixed> after the appropriate boundary. Optional
raw input still includes Laravel’s blank-string bypass. Focused runtime tests
also reject scalars, Stringable objects, and ArrayObject, and confirm that
associative arrays and nested values are preserved unchanged.
Laravel 12.16 also adds the fresh Rule::contains() factory and Contains
object. Laravel 12.22 adds the corresponding Rule::doesntContain() factory
and DoesntContain object with the rule itself. Exact inline factories and
direct construction recover the same array-only output contract. Assigned
objects and dynamic expressions remain opaque because their serialized state
is no longer available at the rule expression.
HTTP normalization also has a known major boundary
Laravel’s default TrimStrings middleware excludes password-related paths in
Laravel 11 and later. Laravel 10 trims them. In optional HTTP-normalization
mode, the extension now removes the blank-string branch for those paths on
Laravel 10 and preserves it on Laravel 11 through 13. If a supported
full-framework version is unavailable, it preserves the branch
conservatively; an illuminate/validation component version alone does not
establish the application’s middleware behavior.
This is already covered by
LaravelInferenceTest::testDefaultPasswordTrimExceptionVariesByLaravelMajor
and the normalized request PHPStan fixtures. The shared version context refines
this behavior alongside rule inference rather than treating it as an unrelated
special case.
Laravel 11.23 changes list output projection
Laravel added the list value predicate in 11.0.3, but it initially remained
different from array during validated() projection. Through Laravel 11.22,
a literal list parent with nested rules is copied in full. Laravel 11.23 adds
list to the parent-reconstruction condition:
(in_array('array', $rules) || in_array('list', $rules))
Consequently, this successful validation changes output at the patch boundary:
$input = ['items' => [['name' => 'Ada']]];
$rules = [
'items' => 'required|list',
'items.*.id' => 'missing',
];
// Laravel 11.22: $input
// Laravel 11.23+: []
The change was introduced by
d8aabd9697e2.
Inference therefore treats a bare list as a reconstruction rule only from
Laravel 11.23. Unknown or unsupported versions retain both the preserved-parent
and reconstructed-output possibilities. Zero wildcard matches remain a
separate branch: without any concrete descendant rule, Laravel keeps the raw
parent even after the reconstruction change.
Parameterized array rules preserve the parent value
Laravel’s nested-output reconstruction distinguishes the literal array rule
from allowed-key forms such as array:name. With a literal array, Laravel
can omit the raw parent value and rebuild validated output from matching child
rules. With array:name, the parameterized rule rejects undeclared keys but
does not trigger that reconstruction path, so Laravel preserves the complete
permitted parent value.
For example, every supported profile preserves name here even though the
only child rule requires child to be missing:
Validator::make(
['payload' => ['name' => 'Ada']],
[
'payload' => 'required|array:name',
'payload.child' => 'missing',
],
)->validated();
// ['payload' => ['name' => 'Ada']]
Laravel 11.23 and later also recognize a literal list rule when deciding
whether to reconstruct nested output; that does not make parameterized
array rules equivalent to bare array. The extension therefore preserves
the allowed-key parent shape around nested rules instead of projecting it
away. The deterministic audit and the structural property catalog both cover
this distinction.
No additional portable boundary was observed
The portable case snapshot is identical at Laravel 10.0, 10.32, and 10.33,
across Laravel 10 and 11, from Laravel 11 to Laravel 12.0, and from Laravel
12.0 to 12.21. After accounting for the numeric-key, strict-integer, and ASCII
boundaries above, later snapshots are also identical within their covered
ranges. The focused hex_color, array-predicate, and list witnesses are
intentionally separate: invoking a rule before its introduction throws, while
the portable corpus cannot exercise list projection on releases where the
rule does not exist.
Across 2,412 case executions on the eighteen profiles, Laravel returns 1,682
successful outputs. Every one is contained in the extension’s inferred type.
There are no observed-unsound, inference-error, or runtime-exception
classifications. Failed inputs are recorded as no-successful-output; only the
preservation-only subset described below is also used as reverse precision
evidence.
This result supports the current conservative unions. It does not establish that unprobed rule interactions, application extensions, or future Laravel patches are sound.
Reverse-direction precision audit
Sound inference requires Laravel’s successful output set to be contained in the inferred type. Exact inference would additionally require the inferred type to contain nothing Laravel can never return. The second relation fails often, so the audit now measures it separately rather than treating imprecision as a conformance failure.
Of the 134 portable cases, 103 are marked as preservation-only precision
probes.
For those cases, the supplied data has the same shape and native values that
validated() would return if validation succeeded. The audit verifies that
the candidate is literally contained in the inferred type and then classifies
Laravel’s behavior:
observed-realizable: Laravel returned that inferred inhabitant unchanged;observed-imprecision: the inferred type contains the candidate, but Laravel rejected it;candidate-outside-inference: inference already excludes the rejected candidate; orcandidate-indeterminate: PHPStan could not establish either relation.
Projection, exclusion, wildcard, and conditional cases are not reverse probes unless raw input is a defensible candidate output. Treating every rejected input as an impossible output would otherwise confuse input filtering with output projection.
The aggregate precision results are:
| Laravel profiles | Realizable | Observed imprecision | Outside inference | Not reverse-probed |
|---|---|---|---|---|
| 10.0 through 12.21 | 71 | 22 | 10 | 31 |
| 12.22 through 13.3 | 67 | 22 | 14 | 31 |
| 13.4 and later | 59 | 22 | 22 | 31 |
Only twelve witnesses change classification by Laravel release:
| Rule | Witnesses realized on older releases | Releases where they become removable |
|---|---|---|
integer:strict | numeric string, integral float, true, compatible Stringable | Laravel 12.22+ |
ascii | integer, float, true, false, null, Stringable, resource, array | Laravel 13.4+ |
No other reverse probe changed classification across the pinned profiles. The
four strict-integer witnesses and eight ASCII witnesses now move from
observed-imprecision to candidate-outside-inference at their verified
boundaries. This confirms that version-aware narrowing removed exactly the
release-dependent branches identified by the runtime differential audit; it
did not reveal another major or minor boundary in the portable corpus.
The reverse audit exposed two version-independent branches that could be removed immediately:
required|nullable|stringno longer includesnull, and the output key is required regardless of rule order. Every pinned profile rejects both missing and null values under an unconditionalrequiredrule.regexandnot_regexno longer include booleans. Every pinned profile across the supported releases requires a string or numeric value before applying the expression.
The remaining invariant imprecision witnesses have less direct causes:
- Rules such as
email,date,multiple_of, digit limits, regular expressions, and scalarinnecessarily accept fewer values than their native PHP supertypes can describe. Numeric string-ruleinparameters now remove the broad integer branch when their native integer equivalence class is safely representable, soin.other_integeris classified ascandidate-outside-inference; its float, numeric-string, andStringableequivalence classes remain broader than PHPStan can express. Float-bearingRule::in()builders additionally retain broadintbecause runtime PHP precision can change their serialized parameter. Other rules may support similar parameter-aware refinements or require predicates PHPStan cannot express. - Optional blank-string bypass currently contributes all
stringvalues even though only blank strings bypass the remaining predicates. PHPStan has no ordinary native type for Laravel’s complete blank-string set. - Broad float and
Stringablebranches remain necessary when some inhabitants pass and others fail, such as integral versus non-integral floats or objects whose string representation differs.
An observed-imprecision classification is therefore a review input, not an
automatic instruction to narrow. A branch is safely removable only when no
successful output in the supported context needs it.
CI enforcement
The exhaustive Nix matrix runs every audit profile once on the PHP floor for its Laravel major. Each profile has a committed Composer lock and an offline Nix dependency closure, and each appears as an independent GitHub Actions job. The deterministic audit compares the recorded contract, checks containment of successful output, and records the reverse precision classification.
Four focused Nix checks separately run
date-rule-parser-audit.php against
exact Laravel 11.40.0, 11.41.0, 11.43.1, and 11.43.2 dependency closures. They
verify the otherwise easy-to-miss distinction between a Date chain nested in a
rule list and the same builder used as a standalone field rule.
A separate PHPUnit matrix runs the complete suite on every supported project PHP version, 8.1 through 8.5. Additional complete-suite jobs install the latest locked Laravel 11, 12, and 13 closures; the root lock supplies Laravel 10. Separating framework-boundary evidence from PHP compatibility retains both dimensions without multiplying them into a 70-job Cartesian matrix.
The exact boundary releases are not substitutes for the floating latest profiles. The former preserve known historical contracts; the latter record the newest release present when their Nix locks were refreshed. Run the portable Composer matrix when checking for a newer patch release, then review and refresh the corresponding lock and baseline deliberately.
Reproducing the audit
The contributor workflow, including focused runtime cases and the relationship between test layers, is documented in the testing and runtime verification guide.
Run the installed Laravel release and print its audit result:
php scripts/inference-audit.php
Compare the installed release with a committed profile:
php scripts/inference-audit.php --baseline=10-latest
List semantic case IDs and run only the cases relevant to an investigation:
php scripts/inference-audit.php \
--list-cases
php scripts/inference-audit.php \
--baseline=10-latest \
--case=present.value \
--case=missing.absent
Run one or more isolated profiles with ordinary PHP and Composer. Exact profiles are cached; floating latest profiles are refreshed:
composer test:audit:matrix -- --profile=12.21.0 --profile=12.22.0
Regenerate a complete baseline only after reviewing Laravel’s behavior and upstream provenance:
composer test:audit:matrix -- --profile=12.22.0 --update
The matrix uses disposable installations below tmp/version-audit and does
not modify the root Composer project. Its Nix wrapper is optional; it only
selects a compatible PHP shell before invoking the same portable runner.
Run the exhaustive bounded property catalogs with:
composer test:property
Every failure must be reproduced against the supported Laravel majors and promoted into the deterministic audit or a focused runtime regression before inference changes.
Cross-version catalog execution
CI runs the complete finite catalogs throughout the supported Laravel/PHP matrix. Version-specific failures must still become focused runtime or audit cases: the catalogs establish containment over their named combinations, while the deterministic audit preserves exact boundary evidence and upstream provenance.
Possible future fuzzing
A manual coverage-guided “probator” may eventually complement these bounded properties, but only with Laravel itself as an independent differential oracle. A useful target would compare Laravel’s and this project’s handling of rule names, parameters, quoting, escaping, regular expressions, dotted paths, and malformed rules under an explicit Laravel profile. Crash-only fuzzing of the current small parser would provide little evidence about inference soundness.
Such a target should remain outside mandatory CI, keep its evolving corpus and crash artifacts in ignored scratch storage, and promote every genuine finding into a deterministic cross-version regression. This is future work; the project does not currently depend on a fuzzing framework.
Version-aware implementation
LaravelVersionContext first reads Composer’s installed-package dataset whose
root installation path matches PHPStan’s working directory. This follows the
Laravel implementation actually installed for analysis and avoids trusting a
stale lockfile. When no matching dataset contains Laravel, it falls back to the
analyzed project’s composer.lock. Both sources prefer laravel/framework
and fall back to illuminate/validation for rule-level behavior. An explicit
phpstanLaravelValidation.laravelVersion setting remains authoritative for
monorepos and other layouts where the working directory is not the relevant
Composer project root.
One shared context is injected into the rule parser and resolver used by
validator, facade, request, and controller inference. The parser normalizes
numeric rule keys, while the resolver specializes integer:strict, ascii,
base64, encoding, extensions, hex_color, array_keys, contains,
in_array_keys, doesnt_contain, list value types, list parent
reconstruction, fresh Rule::array() and Rule::arrayKeys() builder
extraction, fresh date-, numeric-, and string-builder extraction, strict
integer mode, and default HTTP normalization only at the verified boundaries
above.
It ignores installed-package datasets belonging to unrelated project roots,
so a globally installed tool or another registered autoloader cannot silently
select the Laravel contract. The same context contributes its effective
version and framework/component source to PHPStan’s result-cache metadata,
forcing cached file results to be recomputed whenever that inference input
changes.
Auto-detection remains deliberately conservative when both installed-package
data and the lockfile are unavailable, when the authoritative installed
package has a development version without a stable numeric contract, or when
the detected Laravel major is outside the supported 10–13 range. It does not
fall back to a potentially stale lockfile after finding an installed Laravel
package whose version is unstable. A standalone illuminate/validation
version can select rule semantics but cannot prove that full-framework HTTP
middleware defaults apply.
The version-independent required|nullable, regex, and not_regex
opportunities have already been applied and remain covered by the pinned
runtime profiles. Unconditional present and missing now also refine output
presence without conflating key existence with non-blank requiredness; focused
runtime tests cover their named, nested, blank-value, and wildcard behavior
through the pinned profile audits and supported-major PHPUnit jobs.
Environment-dependent rules should remain conservative unless their runtime services can be replaced with deterministic test doubles and their static contract can be stated without booting arbitrary application behavior.
Last reviewed: 2026-08-14.
Development
Enter the reproducible development environment and install ordinary mutable Composer dependencies for interactive work:
nix develop
composer install
Before submitting a change, run the complete normal validation suite:
nix flake check --keep-going -L
That command runs the supported-PHP PHPUnit matrix, PHPStan, php-cs-fixer, Composer and PHP linting, documentation formatting, the mdBook build and link check, Larastan and minimum-PHPStan consumer checks, and the pinned Laravel runtime audits. It does not run mutation testing.
Focused Composer commands remain useful while developing:
composer exec phpunit
composer exec phpstan analyse
composer cs
composer validate --strict
Choose a test layer with Testing and Runtime Verification.
Documentation
Build and check the mdBook:
composer docs
composer docs:check
composer docs:serve
composer docs:check builds the book and fails on broken same-site
relative links in the generated HTML.
The sidebar keeps every page’s h2/h3 outline open. mdBook only injects
headings for the active page, so docs/theme/phpstan-laravel-validation.js
adds the remaining outlines from headingsByChapter. Update that map when
public headings change.
The optional Heliogenesis control is mounted from
docs/theme/phpstan-laravel-validation.js. The unmodified Doctrine
runtime lives under docs/pages/assets/heliogenesis/. The theme marks the
reading plane so the event can light the article and run document
tomography.
The Document Looks Back integration is mounted separately from
docs/pages/assets/document-looks-back/. After it mounts,
window.documentLooksBack is the Doctrine controller, so
window.documentLooksBack.summon() requests one immediate eye. A mount
or renderer failure of either integration leaves the documentation usable.
The copied-runtime tests live in the documentation PHPUnit group. They
compare those assets to the root Composer Doctrine pin. Laravel-matrix
and minimum-PHPStan Nix jobs exclude the group because those lockfiles
do not install that pin.
Akashi formats inline PHP fences in the README, changelog, and docs/:
composer docs:format
composer docs:format:fix
composer cs:fix formats PHP source and those documentation fences.
Akashi checks formatting. It does not execute illustrative fragments as standalone programs.
The published site is
https://jbboehr.github.io/phpstan-laravel-validation/.
.github/workflows/pages.yml
builds the book on develop and master, and deploys only from master.
Mutation testing
Mutation testing uses an isolated toolchain because Infection requires
PHP 8.3 or newer while this package supports PHP 8.1. It is excluded from
nix flake check. Run it explicitly:
nix build -L .#mutation
The package supplies PHP 8.5 with PCOV and preserves the thresholds,
timeouts, test exclusions, and worker count from infection.json5.dist.
It divides the source into five cached shard derivations, each using four
Infection workers, then aggregates the project-wide thresholds. The exhaustive
GitHub workflow schedules those derivations as independent jobs and checks
their uploaded summaries in a final aggregate job. Each runner therefore uses
at most four Infection workers while the five shards can progress in parallel.
The aggregate JSON and a failed timeout-budget check identify each timed-out
mutant by shard, source location, mutator, and Infection ID.
PHPStan type-inference fixtures that cover extension code must run their
first gatherAssertTypes() analysis inside the test body. Data providers
run before PHPUnit starts coverage and can warm PHPStan’s process-level
caches. The test-only AssertsFixtureUnderCoverage trait implements this
pattern. Infection runs only the individual test cases that cover each
mutant.
Tests in the subprocess group remain in the normal suite but are
excluded from mutation testing because child processes cannot observe the
active in-process mutant. The property group is also excluded: rerunning the
complete finite catalogs for each mutant would be disproportionate.
Promoted deterministic regressions remain available to Infection.
The Infection configuration also ignores mutations that make
RuleTreeNode::resolvePath() recurse without consuming input. Those
mutants can exhaust PHP’s native stack before Infection’s timeout can
stop the process.
The php85 Nix development shell includes PCOV for focused manual
investigation.
Nix dependency hashes
Nix builds offline Composer repositories from committed lockfiles. When
Composer dependencies change, update the hashes in
nix/vendor-hashes.nix as described in
CONTRIBUTING.md.
CI
.github/workflows/ci.yml has two
surfaces:
- a conventional PHP baseline job on PHP 8.5 (Composer, PHPUnit, PHPStan, php-cs-fixer);
- an exhaustive Nix matrix generated from flake checks, plus mutation.
The conventional job uploads PHPUnit’s JUnit XML even when the test step fails, preserving per-test outcomes and timings for diagnosis and feedback-loop audits.
Nix matrix jobs use the daemon’s default build directory under
/nix/var/nix/builds. A directory under the runner’s temporary directory can
be inaccessible to Nix build users and fail before the check starts. The report
collector runs with sudo on the hosted Ubuntu runner so it can recover
reports from daemon-owned failed-build directories as well as successful
outputs. PHPUnit and mutation jobs retain failed build directories. Mutation
jobs upload infection.log, infection-summary.json, and
infection-summary.log when available, including after a timeout-threshold
failure. These artifacts preserve mutant identities and diagnostics for
reproduction; an incomplete report still causes aggregation to fail.
Before the Nix matrix fans out, one job builds every pinned Composer vendor closure and saves the resulting Nix store under a derivation-specific cache key. Every matrix job requires an exact cache restore. This keeps the jobs independent without making each runner download the same Composer archives.
A newer run supersedes an older run for the same branch or pull-request ref. Push and pull-request refs remain separate because they validate different Git states.
A documentation failure is a flake-check failure. It is not silent.
Downstream investigations
Pinned application investigations live under
docs/development/. They are evidence for
specific experiments, not user-facing support promises.