
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
- 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
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.
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.
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.
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
and constant setRules() replacements, 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. Lifecycle hooks
can replace the validator. The extension declines rules() inference when
it detects those customizations, unless the exact class is trusted.
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.
| 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 |
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 |
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 concrete FormRequest classes.
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.
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 not trusted implicitly. It bypasses
lifecycle-hook checks. 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. Concrete requests are
discovered from PHPStan’s analysed and scan paths and from the root project’s
Composer autoload and autoload-dev source mappings. Classes outside those
paths, including undiscovered vendor requests, retain Laravel’s broad type.
Adding an exact class to trustedClasses also makes it discoverable, but
that setting simultaneously asserts that its lifecycle hooks are safe. It is
not a risk-free discovery-only option.
validated($key) and safe()
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(), and
safe()->only([...]) chains retain that shape for registry-verified
FormRequests.
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. 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
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.
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
trustedClasses: []
trustedClasses is an exact class list. Subclasses are not trusted
implicitly.
Custom rules
parameters:
phpstanLaravelValidation:
customRules:
classes: []
names: []
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 |
$validator->setRules($rules)->validated() | Replacement shape when the return is used |
FormRequest::validated() / supported safe() | Validated shape 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.
setRules()
Larastan provides its own stub for Illuminate\Validation\Validator, and
PHPStan does not merge multiple stubs for the same class. When both
extensions are installed, Larastan’s stub takes precedence, so an ignored
setRules() return can leave the validator’s previously inferred rules in
place. Chain the call or assign its return value:
$validator->setRules($rules)->validated();
$validator = $validator->setRules($rules);
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().
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, current_password | non-empty-string |
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 | float|int|non-empty-string (date_format rejects DateTimeInterface) |
These rules do not parse input into a canonical date object.
Numbers
| Rule | Successful native type |
|---|---|
numeric, decimal, digits, digits_between, max_digits, min_digits, multiple_of | float|int|numeric-string |
integer | float|int|numeric-string|Stringable|true |
integer:strict | int from Laravel 12.22; earlier releases ignore strict and keep the ordinary integer union |
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: float|int|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() has the same
native family as a formatted date. Laravel 12.3 changed how format()
serializes; both forms produce that same sound output family.
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. It models conventional request
validation and falls back for known lifecycle customization. It cannot
globally track an inherited setValidator() call that replaces the
validator before validated(). See
FormRequest Inference.
Larastan stubs
Larastan provides its own stub for Illuminate\Validation\Validator.
PHPStan does not merge multiple stubs for the same class. When both
extensions are installed, Larastan’s stub takes precedence. Use the
setRules() return value. See
Supported Entry Points.
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.
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 13.4 | ascii requires a native string |
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 built-in validation-rule surface and maps
it 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, the current Laravel 13.25 surface corresponds exactly to the
114 names reserved by TypeResolver::BUILT_IN_RULE_NAMES.
Laravel added these rules and rule-object features during the supported major range:
- 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:
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.
Status definitions
The tables use three 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.
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 names | Focused static coverage | Meaning |
|---|---|---|---|
| Direct type contribution | 57 | 57 | A native value type is emitted and has dedicated focused static coverage |
| Explicitly neutral | 49 | 18 | 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 |
| Total reserved names | 114 | 89 files | Covers the current Laravel 13.25 name inventory, including Enum and Password |
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 57 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, CurrentPassword, 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 |
| Numeric checks | Decimal, Digits, DigitsBetween, Integer, MaxDigits, MinDigits, MultipleOf, Numeric | Numeric strings and the native numeric values Laravel accepts and preserves |
| 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 49 names are recognized and deliberately contribute no local value type:
| Family | Rules | Why a neutral contribution is currently conservative |
|---|---|---|
| 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.
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. Laravel 13.24 exposes
fluent builders through Illuminate\Validation\Rule and classes under
Illuminate\Validation\Rules.
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 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 many bounded combinations remain sound? | Eris property suite | 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'
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.
Eris samples those catalogs with replacement. 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.
The default seed is fixed by phpunit.xml.dist so CI is reproducible:
composer test:property
ERIS_SEED=123456 composer test:property
The catalog integrity test locks the intended sizes and requires unique, descriptive IDs. A generated counterexample 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
GitHub’s exhaustive Nix matrix adds that package to the normal check set. The
package builds the four source shards independently, each with the configured
four Infection workers. GitHub schedules the shard derivations serially to
avoid oversubscribing its four-core runner. The aggregate derivation enforces
the project-wide MSI, covered-MSI, timeout, and expected-ignore behavior.
Ordinary nix flake check never runs it.
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 13.4 | Portable corpus | ascii requires a native string |
| 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.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.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 Eris property suite takes 250 seed-dependent draws in each of three bounded domains: scalar presence and native representations, nested projection and wildcards, and cross-field presence and exclusion. Their finite catalogs contain 1,620, 180, and 280 possible combinations respectively; draws are made with replacement and are not claims of exhaustive coverage. Each property requires at least 30 percent of its trials to produce successful Laravel output so a mostly rejected sample cannot pass vacuously. It then runs the same runtime-to-static containment check without creating snapshots.
The fixed default seed makes CI reproducible, while an explicit ERIS_SEED
explores or replays another input sequence. Property testing broadens the
observed evidence; it does not prove universal soundness.
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.*, digits*, decimal, multiple_of, max_digits, min_digits, 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 |
| 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, validator unions, constant setRules() | 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
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 only the bounded property suite with its default seed, or select another seed to explore and replay a different sequence:
composer test:property
ERIS_SEED=123456 composer test:property
Every counterexample must be reproduced against the supported Laravel majors and promoted into the deterministic audit or a focused runtime regression before inference changes.
Possible future cross-version seed sweeps
A local sweep of seeds 1 through 250 on Laravel 10.50.2 completed 187,500 generated trials without finding a containment failure. Across the sweep, the generated index combinations visited all 1,620 scalar, the then-current 100 structural, and 280 conditional catalog entries at least once. This strengthens the local evidence but does not exercise those sequences against every supported Laravel release. The structural catalog has since grown to 180.
CI already runs the reproducible default seed throughout the Laravel/PHP matrix. A useful lower-priority follow-up is a periodic or manually triggered cross-version sweep using several additional fixed seeds. It need not multiply the mandatory pull-request matrix. Any version-specific counterexample should be promoted into the deterministic audit or a focused runtime regression so it remains covered without depending on random discovery.
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 four cached shard derivations, each using four
Infection workers, then aggregates the project-wide thresholds. GitHub
builds those shards serially so a four-core runner is not oversubscribed.
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
hundreds of generated cases 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.
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.