Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Inferences for Laravel Validation + PHPStan

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
// }>}

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:

OptionDefaultWhen to change it
laravelVersionautoPHPStan’s working directory is not the Composer project that owns Laravel
assumeHttpInputNormalizationfalseRequest validation always runs after Laravel’s default trim/empty-string middleware
formRequests.enabledfalseYou 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-validation describes 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

Validates, doesn’t parse.

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:name rejects an input array containing keys other than name.
  • With the validator factory’s default exclusion setting, adding user.name => required|string below a bare array parent 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:name is 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 list another 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 as 1, 0 rather than 0, 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 exists and unique; 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.

SituationHonest structural descriptionDistinct cause
required with integerfloat, int, numeric-string, Stringable, or trueLaravel preserves several native representations admitted by the predicate.
optional arrayoptional array or stringA blank string can bypass the predicate.
conditional acceptancea broad value when the branch is unknownAn inactive branch accepts values excluded by the active branch.
conditional exclusionan optional output offsetA present input can be removed from the result.
wildcard-only descendantsan optional parent offsetWildcard expansion can find no elements.
bare arraya general array valueUnspecified nested keys are retained.
unknown custom predicatemixed, intersected with adjacent known predicatesRuntime 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:

LaravelReleaseCommit
1010.50.23ff39b7a9b83
1111.55.0dc7ec34ae95b
1212.64.0727a8ea2949c
1313.23.092a707229148

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.

ClaimLaravel runtime coveragePHPStan inference coverage
integer can preserve non-integersLaravelInferenceTest::testIntegerRuleCanPreserveNonIntegerValuestests/rules/integer.php
integer:strict differs by Laravel releaseLaravelInferenceTest::testIntegerStrictRuleFollowsRuntimeSupport and testIntegerStrictRuleAcceptsAndPreservesNativeIntegerBoundary 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 stringLaravelInferenceTest::testBase64RuleFollowsRuntimeVersionBoundaryBoundary coverage in tests/TypeResolverTest.php and tests/version-aware/base64.php
Scalar in preserves coercible inputs and admits parameter-dependent integer equivalence classesLaravelInferenceTest::testScalarInRuleAcceptsRuntimeValues, testNumericInRuleNarrowsOnlyItsRepresentableNativeIntegerClass, and testLargeFloatingPointInParameterAcceptsMultipleNativeIntegerstests/rules/in.php and TypeResolverTest::testNumericInParametersNarrowOnlyRepresentableIntegerClasses
Optional blanks bypass non-implicit rulesLaravelInferenceTest::testBlankStringBypassesOptionalNonImplicitRulestests/structure/empty-string.php
HTTP normalization changes blank behaviorLaravelInferenceTest::testDefaultHttpInputNormalizationChangesOptionalBlankBehavior, testTrimStringsAloneDoesNotEliminateBlankStringBypass, and testDefaultPasswordTrimExceptionVariesByLaravelMajortests/normalized/request.php, tests/structure/request.php, and tests/version-aware/inference.php
Conditional acceptance broadens valuesLaravelInferenceTest::testConditionalValueRulesRemainConservativetests/rules/accepted-if.php
Conditional exclusion changes shapeLaravelInferenceTest::testConditionalExclusionChangesTheValidatedShapetests/rules/exclude-if.php
Definite conditional presence and absence can refine shape experimentallyConditionalPresenceLaravelRuntimeTest::testConditionalPresenceMatchesExperimentalInference, testActiveConditionalPresenceRulesRejectTheOppositeShape, testConditionalPresentRulesFollowRuntimeAndInferenceBoundary, and the exact-profile conditional-presence-rule-audit.php probetests/conditional-presence/inference.php, tests/conditional-presence/before-introduction.php, and TypeResolverTest::testExperimentalConditionalPresenceInferenceResolvesDefiniteConditions
Required wildcard descendants may match nothingLaravelInferenceTest::testRequiredWildcardDescendantDoesNotRequireMissingParenttests/structure/wildcard.php
Bare arrays preserve nested keysLaravelInferenceTest::testArrayRuleWithoutKeyParametersPreservesNestedKeystests/rules/array.php
Array key lists reject undeclared keysLaravelInferenceTest::testArrayRuleKeyParametersRejectUndeclaredNestedKeystests/rules/array.php
Nested child rules project validated keysLaravelInferenceTest::testParentAndChildRulesAcceptRuntimeOutputtests/structure/parent-rules.php
Factory configuration changes nested projectionLaravelInferenceTest::testFactoryUnvalidatedArrayKeyModesMatchInferencetests/include-unvalidated-array-keys/inference.php
Parameterized arrays preserve the permitted parent around nested rulesPresenceLaravelRuntimeTest::testRuntimeProjection (named parameterized-parent cases) and the version-audit snapshotstests/rules/missing.php and TypeResolverTest::testParameterizedArrayParentIsPreservedAroundNestedRules
Literal list joins nested reconstruction in Laravel 11.23; projection order can preserve, sparsify, or reorder its keysLaravelInferenceTest::testListRuleFollowsRuntimeVersionBoundary and testFactoryUnvalidatedArrayKeyModesMatchInference on the 11.22 and 11.23 profilestests/version-aware/list.php, tests/version-aware/list-projection.php, and TypeResolverTest::testListParentProjectionChangesInLaravel1123
Custom predicates preserve successful original valuesCustomRulesLaravelRuntimeTest::testObjectRulesPreserveSuccessfulValuesAndRejectOthers, testClosureRulePreservesSuccessfulOriginalValue, and testRegisteredStringRulePreservesSuccessfulOriginalValuetests/custom-rules/inference.php
FormRequest lifecycle hooks can change effective rules and later outputFormRequestLaravelRuntimeTest::testWithValidatorCanReplaceTheEffectiveRules, testIntermediateWithValidatorHookCanReplaceTheEffectiveRules, testTraitWithValidatorHookCanReplaceTheEffectiveRules, testPassedValidationCanReplaceRulesAfterSuccessfulValidation, and testCustomValidatorCanIgnoreRulesMethodtests/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

See Custom Validation Rules.

parameters:
    phpstanLaravelValidation:
        customRules:
            classes: []
            names: []

Supported Entry Points

The same rule-set inference applies to these statically resolvable calls.

Entry pointInferred 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) facadeValidated shape
Request::validate($rules)Validated shape
Controller $this->validate($request, $rules)Validated shape
validator($data, $rules)->validated() helperValidated 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:

ExpressionWhy it is conservative
Assigned or stored builder objectPHPStan retains the class, not constructor arguments or later mutation
Subclass of a supported builderThe 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 argumentsThe serialized rule can change at runtime
Arrayable or runtime Stringable argumentsAnalysis does not execute toArray() or __toString()
First-class callables (Rule::in(...))No argument list is supplied
Fluent ->when() / ->unless() on a builderThe selected state is a runtime program
Macros and unknown fluent methodsNo generally available static contract
Closures, Rule::forEach, and NestedRulesRuntime 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

RuleSuccessful native type
accepted'yes'|'on'|'1'|1|'true'|true
declined'no'|'off'|'0'|0|'false'|false
booleanbool|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

RuleSuccessful native type
string, lowercase, uppercasestring
email, alpha, url, uuid, ulid, ip, ipv4, ipv6, mac_address, timezone, active_url, current_passwordnon-empty-string

Coercive text

These rules admit values Laravel stringifies for the check, then preserve the original native value.

RuleSuccessful native type
alpha_dashfloat|int|non-empty-string
alpha_numfloat|int<0, max>|non-empty-string
jsonfloat|int|non-empty-string|Stringable|true
regex, not_regexfloat|int|string
asciistring from Laravel 13.4; earlier supported releases preserve a broad weakly coerced union
hex_colornon-empty-string from Laravel 13.4; non-empty-string|Stringable from 10.33 through 13.3; mixed before 10.33
base64non-empty-string from Laravel 13.21; mixed before that

Dates

RuleSuccessful native type
date, date_equals, after, after_or_equal, before, before_or_equalDateTimeInterface|float|int|non-empty-string
date_formatfloat|int|non-empty-string (date_format rejects DateTimeInterface)

These rules do not parse input into a canonical date object.

Numbers

RuleSuccessful native type
numeric, decimal, digits, digits_between, max_digits, min_digits, multiple_offloat|int|numeric-string
integerfloat|int|numeric-string|Stringable|true
integer:strictint 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

RuleSuccessful native type
arrayarray
array:name,emailarray{name?: mixed, email?: mixed}
array_keys:...Optional-key shape from Laravel 13.24; mixed before that
listlist<mixed> from Laravel 11.0.3; mixed before that
required_array_keys:namearray intersected with a required name offset
contains, doesnt_contain, in_array_keysarray 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

RuleSuccessful native type
file, image, mimes, mimetypes, dimensionsSymfony\Component\HttpFoundation\File\File
extensionsSame Symfony File type from Laravel 10.34; mixed before that
encodingBroad 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.

FamilyRules
Size and comparisonbetween, gt, gte, lt, lte, max, min, size
Cross-field and domain predicatesaccepted_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 projectionbail, 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

RuleSuccessful output
requiredKey is present. Blank strings fail.
presentKey is present. Blank strings may bypass adjacent non-implicit rules.
filledIf the key is present, it is non-blank.
nullablenull is allowed. Does not make a missing key required.
sometimesThe key remains optional.
missingThe path is omitted from successful output.
excludeThe 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 ruleNested children
Bare array or, from Laravel 11.23, literal listParent is rebuilt from validated descendants
array:name,email or array_keys:...Complete permitted parent is preserved
includeUnvalidatedArrayKeys: trueAffected 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

BoundaryEffect
Laravel 12.0Top-level literal integer rule keys are preserved instead of reindexed from 0
Laravel 12.22integer:strict requires a native integer
Laravel 13.4ascii requires a native string

Rules and builders introduced in the supported range

VersionWhat changes for inference
10.21.1In / NotIn serialize enum cases
10.32present_if / present_unless exist; experimental presence refinement may apply
10.33hex_color; Rule::unless()
10.34extensions
10.36In / NotIn constructors accept scalar, variadic, and Arrayable inputs
10.46Enum::only() / Enum::except()
11.0prohibited_if_accepted, prohibited_if_declined; Laravel 10 trims password fields, 11+ does not
11.0.3list; required_if_declined
11.7Rule::array()
11.8contains
11.23Literal list participates in nested reconstruction; Dimensions ratio methods
11.40Fluent Date builder
11.41Date chains usable inside rule lists
11.42Fluent Numeric builder
11.43.2Date chains usable as standalone field rules
12.16in_array_keys; Rule::contains()
12.22doesnt_contain; Rule::doesntContain()
12.40encoding
12.44Rule::dateTime() and now-relative date predicates
12.55Numeric::integer(strict: true); Rule::string()
13.4hex_color rejects compatible Stringable objects
13.21Native-string-only base64
13.24array_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:

LaravelFixture sourceTrait validatorsNotes
10.50.23ff39b7a102Pinned fixture
11.55.0dc7ec34a107Pinned fixture
12.64.0727a8ea2110Pinned fixture; 12.65.0 and 12.66.0 have the same rule inventory
13.23.092a70722111Pinned fixture
13.24.06d481710112Pinned 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: In and NotIn builders gain enum-value serialization, and their concrete constructors gain scalar, variadic, and Arrayable inputs in 10.36;
  • Laravel 10.33: hex_color and Rule::unless(), followed by extensions in 10.34 and Enum::only() / Enum::except() in 10.46;
  • Laravel 11: prohibited_if_accepted, prohibited_if_declined, and required_if_declined, followed by list in Laravel 11.0.3, the Rule::array() builder in 11.7, and contains in 11.8; Laravel 11.23 later makes a literal list participate in nested-output reconstruction and adds the extended Dimensions ratio methods; Laravel 11.40 adds the fluent Date builder, 11.41 makes its chains usable inside rule lists, 11.42 adds the fluent Numeric builder, and 11.43.2 makes Date chains usable as standalone field rules;
  • Laravel 12: in_array_keys and Rule::contains() in 12.16, followed by doesnt_contain and Rule::doesntContain() in 12.22, encoding in 12.40, Rule::dateTime() and the date builder’s now-relative predicates in 12.44, and strict integer mode on the Numeric builder plus the fluent StringRule builder in 12.55;
  • Laravel 13.21: base64, followed by array_keys in 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-mixed PHPStan 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 handlingRule namesFocused static coverageMeaning
Direct type contribution5757A native value type is emitted and has dedicated focused static coverage
Explicitly neutral4918The rule does not independently narrow the local value type, whether intentionally or because a correlated model is unavailable
Conservative mixed fallback80No built-in accepted-value model is applied
Total reserved names11489 filesCovers 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:

FamilyRulesCurrent contribution
Exact accepted setsAccepted, Boolean, Declined, InLiteral 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 predicatesActiveUrl, Alpha, CurrentPassword, Email, Ip, Ipv4, Ipv6, MacAddress, Timezone, Ulid, Url, UuidUsually non-empty-string
Native string checksLowercase, String, Uppercasestring
Coercive text checksAlphaDash, AlphaNum, Json, NotRegex, RegexUnions containing the native scalar or Stringable values Laravel preserves
Date checksAfter, AfterOrEqual, Before, BeforeOrEqual, Date, DateEquals, DateFormatNumeric scalars, non-empty strings, and where applicable DateTimeInterface
Numeric checksDecimal, Digits, DigitsBetween, Integer, MaxDigits, MinDigits, MultipleOf, NumericNumeric strings and the native numeric values Laravel accepts and preserves
Arrays and filesArray, RequiredArrayKeys, Dimensions, File, Image, Mimes, MimetypesArray 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 rulesEnumStatically visible enum cases, backing values, and the weakly coerced native values Laravel preserves; literal only/except state is modeled from Laravel 10.46
Version-sensitiveArrayKeys, Ascii, Base64, Contains, DoesntContain, Encoding, Extensions, HexColor, InArrayKeys, ListArrayKeys 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:

FamilyRulesWhy a neutral contribution is currently conservative
Size and comparisonBetween, Gt, Gte, Lt, Lte, Max, Min, SizeThe accepted native family depends on adjacent numeric, array, string, or file rules and on runtime values
Cross-field and domain predicatesAcceptedIf, Confirmed, DeclinedIf, Different, Distinct, DoesntEndWith, DoesntStartWith, EndsWith, Exists, Filled, InArray, NotIn, Password, Same, StartsWith, UniqueThese are predicates or environment-dependent checks; several need correlated types to improve safely
Flow and output rulesBail, Exclude, ExcludeIf, ExcludeUnless, ExcludeWith, ExcludeWithout, Missing, MissingIf, MissingUnless, Nullable, Present, PresentIf, PresentUnless, Prohibited, ProhibitedIf, ProhibitedUnless, Prohibits, Required, RequiredIf, RequiredUnless, RequiredWith, RequiredWithAll, RequiredWithout, RequiredWithoutAll, SometimesTheir 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.

RulesIntroducedLaravel consequenceExisting runtime evidenceCandidate treatment
MissingWith, MissingWithAll10Conditionally constrain whether a path may existFixtures for all supported majorsCorrelated optionality for the remaining conditional family
PresentWith, PresentWithAll10Conditionally constrain path presence without requiring a non-blank valueFixtures for all supported majorsCorrelated conditional presence
RequiredIfAccepted, RequiredIfDeclined10 / 11Conditionally require a field based on another field’s accepted or declined valueFixtures from introduction onwardCorrelated presence unions
ProhibitedIfAccepted, ProhibitedIfDeclined11Conditionally restrict a field based on another fieldLaravel 11 through 13 fixturesCorrelated 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 familyLaravel behaviorCurrent shapeFinding
RequiredKey must exist and contain a non-empty valueRequired keyModeled
Accepted, DeclinedAt a matched path, each calls Laravel’s required check before checking its exact accepted setRequired matched path with an exact value union; zero-match wildcard parents remain optionalModeled
PresentA matched path must exist, but blank and null values are not rejected by presence aloneRequired matched path with blank-value bypass preserved; zero-match wildcard parents remain optionalModeled
MissingA matched path must not existOmitted named path and bare-array missing-only projection; parameterized array parents remainModeled
ExcludeRemoves the path from validated outputOmitted keyModeled
Conditional Exclude*May remove the path according to other runtime dataOptional keyConservative aggregate model; correlation is lost
Conditional required, accepted, declined, present, missing, and prohibited rulesPresence or permitted emptiness depends on other fieldsUsually an optional broad key; an experimental option resolves definite direct PresentIf, PresentUnless, MissingIf, and MissingUnless outcomesConservative but often imprecise; a precise model may require correlated shape unions
RequiredArrayKeysRequires named offsets inside a present array, but does not itself project those keys into outputGeneral arrays intersected with required-offset constraints; matching direct child rules become required only when projection guarantees themModeled

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-boolean Rule::requiredIf(), Rule::excludeIf(), and Rule::prohibitedIf(), literal-boolean Rule::when() and Rule::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(), and Rule::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 mixed unless they have an explicit custom contract;
  • Stringable builders that do not implement a predicate contract are opaque, making the affected path optional and mixed.
  • callbacks, macros, and other open-ended runtime programs remain opaque.
Current extractionRepresentative Laravel objectsConsequence
Dedicated built-in extractionEnum, 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 typeAnyOf, Can, Email, Password, assigned or unsupported File / ImageFile buildersAdjacent built-in string rules survive, but object state and built-in semantics are not recovered
Opaque Stringable builderCallback-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 chainsThe path widens to optional mixed, even when the builder serializes to a supported string rule
Opaque runtime programDynamic or callback-driven Rule::when() / Rule::unless(), Rule::forEach, NestedRules, macrosRuntime 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

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

QuestionTest layerTypical location
Does the parser or resolver build the intended type?Fast unit testtests/RuleTreeNodeTest.php, tests/TypeResolverTest.php
What does Laravel accept and return?Focused runtime test with named casestests/*LaravelRuntimeTest.php
What type does PHPStan show at a real call site?Explicit assertType() fixturetests/rules, tests/structure, tests/version-aware
Is behavior stable across supported Laravel releases?Deterministic inference audittests/Support/InferenceAuditCases.php, tests/fixtures/version-audit
Do many bounded combinations remain sound?Eris property suitetests/Property/InferenceSoundnessPropertyTest.php
Does the whole extension still work?Full PHPUnit and PHPStan suitescomposer 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

  1. 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.
  2. Reproduce the behavior against every supported Laravel major. If a patch boundary is suspected, test both sides of it.
  3. Add or update the parser/resolver unit assertion.
  4. Add an explicit PHPStan fixture assertion at the relevant entry point.
  5. Add a deterministic audit case when the scenario is adversarial, version-sensitive, or useful as a long-term runtime witness.
  6. 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

BoundarySourceEffect
Laravel 12.0Portable corpusTop-level literal integer rule keys are preserved instead of reindexed from 0
Laravel 12.22Portable corpusinteger:strict requires a native integer
Laravel 13.4Portable corpusascii requires a native string
10.21.1Builder fixturesIn / NotIn serialize enum cases
10.32Runtime suitepresent_if / present_unless exist
10.33Runtime suitehex_color; Rule::unless()
10.34Runtime suiteextensions
10.36Builder fixturesIn / NotIn constructors accept scalar, variadic, and Arrayable inputs
10.46Builder fixturesEnum::only() / Enum::except()
11.0Runtime suiteLaravel 10 trims password fields; 11+ does not
11.0.3Runtime suitelist; required_if_declined
11.7Builder fixturesRule::array()
11.23Runtime suiteLiteral list joins nested reconstruction; Dimensions ratio methods
11.40–11.43.2Builder fixturesFluent Date builder, then list and standalone expansion
11.42Builder fixturesFluent Numeric builder
12.16Runtime suitein_array_keys; Rule::contains()
12.22Runtime suitedoesnt_contain; Rule::doesntContain()
12.40Runtime suiteencoding
12.44Builder fixturesRule::dateTime() and now-relative date predicates
12.55Builder fixturesNumeric::integer(strict: true); Rule::string()
13.4Runtime suitehex_color rejects compatible Stringable objects
13.21Runtime suiteNative-string-only base64
13.24Runtime suitearray_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

ProfileConstraintPHP floorRecorded releaseUpstream commit
10.0.010.0.08.110.0.0be2ddb5c31b0
10.32.110.32.18.110.32.1b30e44f20d24
10.33.010.33.08.110.33.04536872e3e5b
10.34.010.34.08.110.34.092b78fdd1f38
10-latest^10.08.110.50.374e222cee687
11.0.011.0.08.211.0.06089f679d6d2
11.22.011.22.08.211.22.0868c75beacc4
11.23.011.23.08.211.23.0576f6f5d63f6
11-latest^11.08.211.55.18d786e25c5fb
12.0.012.0.08.212.0.0bd8aeb64d3f9
12.21.012.21.08.212.21.0ac8c4e73bf1b
12.22.012.22.08.212.22.06ab00c913ef6
12.39.012.39.08.212.39.01a6176129ef2
12.40.012.40.08.212.40.03159215d904a
12-latest^12.08.212.66.082a53323c701
13.0.013.0.08.313.0.03e33f431a053
13.3.013.3.08.313.3.0118b7063c44a
13.4.013.4.08.313.4.0912de244f88a
13.20.013.20.08.313.20.0b9d1bccad5fb
13.21.013.21.08.313.21.0d1e02ce7b7e2
13.23.013.23.08.313.23.092a707229148
13.24.013.24.08.313.24.06d481710375d
13-latest^13.08.313.25.0ed36fe882bd4

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:

  1. runs the rule through Laravel’s own Validation\Factory;
  2. records whether validation failed, threw, or returned validated output;
  3. converts successful output into a PHPStan type;
  4. resolves the same rule with this extension and the exact installed Laravel version; and
  5. 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

AreaRepresentative probesResult
Accepted and declined valuesaccepted.true, accepted_if.inactive, declined.false, declined_if.inactiveNo observed release difference
Boolean and numeric predicatesboolean.*, integer.*, numeric.*, digits*, decimal, multiple_of, max_digits, min_digits, and fresh fluent numeric buildersinteger: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 predicatesalpha*, ascii.*, string, lowercase, uppercase, regex, not_regex, and fresh fluent string buildersascii 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 colorsvalid strings, compatible Stringable, optional blank input, and unsupported-rule behaviorRule introduction at 10.33; native-string boundary at 13.4, covered by the cross-profile PHPUnit suite
File extensionsvalid and failed uploads, a compatible Symfony file subclass, invalid native values, optional blank input, and unsupported-rule behaviorextensions begins at Laravel 10.34, covered by the cross-profile PHPUnit suite rather than the portable audit corpus
Character encodingstrings, arrays, scalars, Stringable, null, valid and invalid file contents, invalid uploads and parameters, and unsupported-rule behaviorencoding begins at Laravel 12.40, covered by the cross-profile PHPUnit suite rather than the portable audit corpus
JSON, dates, and membershipjson.*, date*, comparisons, fresh fluent date builders, scalar in, and fresh Rule::in() / Rule::notIn() builders and exact constructorsScalar 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 identifiersemail, ip, ipv4, ipv6, mac_address, timezone, url, uuid, ulidNo observed release difference
Arrays and projectionbare and keyed arrays, parameterized-parent preservation, required array offsets, numeric rule keys, nested child projection, wildcards, parent-plus-child rules, and fresh Rule::array() buildersNumeric 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 predicatesrequired and optional values, non-array rejection, preserved associative and nested arrayscontains, 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 keyspermitted subsets, extra-key rejection, numeric keys, empty parameters, blank bypass, nested rules, and the fluent builderarray_keys begins at Laravel 13.24, covered by the cross-profile PHPUnit suite rather than the portable audit corpus
Enum objectspure, string-backed, and integer-backed cases; weakly coerced preserved values; optional blanks; and literal filtersBase 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 dimensionsa real one-pixel image file, incorrect dimensions, native path strings, optional blanks, nullable input, and fresh Dimensions buildersNo 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 conditionsoptional blanks, nullable, present, missing, zero-match wildcard parent preservation, confirmed, required_if, exclude_if, and literal-boolean RequiredIf / ExcludeIf / ProhibitedIf buildersNo observed release difference; the builders’ true rules and false empty-rule projection markers are covered by cross-profile PHPUnit
Default HTTP middlewarepassword-path trimming before validationLaravel 10 versus 11+ boundary covered by the cross-profile PHPUnit suite
Static entry pointsfacade, factory, request, controller, helper, validator unions, constant setRules()Covered by the existing PHPStan fixture suite
Environment-dependent behaviorother file and image metadata, database, DNS, password-rule service checks, custom rulesCatalogued 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; or
  • candidate-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 profilesRealizableObserved imprecisionOutside inferenceNot reverse-probed
10.0 through 12.2171221031
12.22 through 13.367221431
13.4 and later59222231

Only twelve witnesses change classification by Laravel release:

RuleWitnesses realized on older releasesReleases where they become removable
integer:strictnumeric string, integral float, true, compatible StringableLaravel 12.22+
asciiinteger, float, true, false, null, Stringable, resource, arrayLaravel 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|string no longer includes null, and the output key is required regardless of rule order. Every pinned profile rejects both missing and null values under an unconditional required rule.
  • regex and not_regex no 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 scalar in necessarily accept fewer values than their native PHP supertypes can describe. Numeric string-rule in parameters now remove the broad integer branch when their native integer equivalence class is safely representable, so in.other_integer is classified as candidate-outside-inference; its float, numeric-string, and Stringable equivalence classes remain broader than PHPStan can express. Float-bearing Rule::in() builders additionally retain broad int because 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 string values 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 Stringable branches 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.