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

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.