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-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.