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

Parsing Validated Output

Laravel’s built-in validation rules normally test the original input and preserve its PHP representation. The experimental Rensei rules provide an explicit exception: a parsing rule either produces a value of its declared type or fails validation.

Warning

Rensei is experimental. Its runtime and inferred contracts may change before the feature is declared stable.

Installation and compatibility

Parsing rules execute inside the application, not only during PHPStan analysis. If a deployed code path uses Parse::*, this package must remain installed in production. Installing it only with composer require --dev and then deploying with composer install --no-dev removes the runtime classes.

For runtime use in a Laravel application, install it without --dev:

composer require jbboehr/phpstan-laravel-validation

Analysis-only users can continue to use composer require --dev. The current package also installs PHPStan and nikic/php-parser as production dependencies when installed under require.

The analysis extension supports Laravel 10.0 through 13. The parsing runtime requires Laravel 10.7 or newer because it uses Validator::setValue() for checked final write-back. Composer cannot express a version floor that applies only when an optional class is used, so an older Laravel release fails with UnsupportedLaravelVersion when it attempts to run a parser. When PHPStan can identify a supported laravel/framework version below 10.7, it also reports each statically resolvable parser use as laravelValidation.parsingRuleLaravelVersion. If the version is unavailable, analysis stays conservative about compatibility and the runtime guard still fails closed.

Escape literal dots in rule paths as Laravel requires: a\.b addresses the key a.b, while a.b addresses b inside a. Both paths can have parsing rules, but each must use a separate parser instance. Laravel passes the same decoded name to both callbacks, so sharing one parser instance between them fails validation. Sharing an instance across paths with distinct decoded names remains supported. Escaped paths also work on older parsing-supported releases that use unmarked internal placeholders.

No Rensei-specific PHPStan configuration flag is required. The extension reads the produced type from the concrete parsing rule’s ParsingRule<T> binding.

Using the rules at runtime also makes this package a deployed dependency. It is licensed under AGPL-3.0-or-later, which should be considered before moving it from an analysis-only dependency.

Basic use

use Illuminate\Support\Facades\Validator;
use jbboehr\Rensei\Parse;

enum AccountStatus: string
{
    case Pending = 'pending';
    case Active = 'active';
}

$validator = Validator::make($input, [
    'age' => ['required', Parse::integer()],
    'ratio' => ['required', Parse::float()],
    'identifier' => ['required', Parse::string()],
    'payload' => ['required', Parse::base64()],
    'enabled' => ['required', Parse::boolean()],
    'terms' => ['required', Parse::accepted()],
    'opt_out' => ['required', Parse::declined()],
    'status' => ['required', Parse::enum(AccountStatus::class)],
    'starts_at' => ['required', Parse::dateTime()],
    'timezone' => ['required', Parse::timezone()],
]);

$validated = $validator->validated();
\PHPStan\dumpType($validated);
// array{age: int, ratio: float, identifier: string, payload: non-empty-string,
//     enabled: bool, terms: true, opt_out: false, status: AccountStatus,
//     starts_at: DateTimeImmutable, timezone: DateTimeZone}

$safe = $validator->safe()->all();

For corresponding input, both output calls contain 42, 1.5, a string identifier, decoded bytes, false, literal true and false acceptance values, AccountStatus::Active, a DateTimeImmutable, and a DateTimeZone.

PHPStan deliberately retains Laravel’s broad array type for $validator->safe()->all(). A factory may use Factory::resolver() to return a custom Validator whose virtual validated() method changes the payload, so the wrapper cannot receive the ordinary validator’s structural contract soundly. Supported safe() projections on conventional FormRequests can carry the parsed shape when FormRequest inference is enabled and all discovered concrete request contracts can be resolved.

The caller’s $input array is not rewritten. A request also retains its original values through $request->all() and $request->input(). Parsing changes the successful validator output, not the incoming request.

Exact parser grammars

Parse::integer()

Accepts any native int and canonical decimal strings matching ^-?(0|[1-9][0-9]*)\z within the platform integer range. The \z anchor is intentional: unlike $, it does not match before a final newline. It produces int.

It rejects floats, booleans, leading +, leading zeroes, whitespace, decimals, scientific notation, trailing data, and integer overflow. For example, '42' becomes 42 and '-0' becomes 0, while '042', '+42', '42.0', and 42.0 fail validation.

Parse::float()

Accepts finite native floats, widens native ints, and accepts canonical ASCII decimal strings matching ^-?(0|[1-9][0-9]*)(\.[0-9]+)?\z. It produces float.

It rejects booleans, leading +, leading zeroes, whitespace, scientific notation, locale-specific separators, INF, NAN, and decimal strings whose conversion overflows to infinity. For example, '42' becomes 42.0 and '-1.5' becomes -1.5, while '01.5', '.5', '1e3', and INF fail. Conversion to PHP’s native float may lose precision or underflow to zero; the parser promises a finite float, not arbitrary-precision decimal arithmetic. '-0' and '-0.0' preserve IEEE 754 negative zero.

Parse::string()

Accepts native strings unchanged, converts native integers to their lossless decimal representation, and converts Stringable objects through __toString(). It produces string. A Stringable conversion that throws or otherwise fails becomes an ordinary validation failure.

It rejects floats, booleans, arrays, resources, and non-Stringable objects. Generic float stringification would introduce a mutable precision and spelling policy; PHP’s boolean casts would turn true into '1' and false into ''. Those conversions are not part of this grammar.

This parser is not Laravel’s string predicate under another name:

'string'        // accepts native strings and preserves them
Parse::string() // deliberately converts int and Stringable input

Adjacent Laravel rules still inspect the original representation. For example, ['integer', Parse::string()] accepts native integers and strings that satisfy Laravel’s non-strict integer predicate, then returns a string. It does not impose the canonical grammar used by Parse::integer(): strings such as '+42', ' 42', and '42 ' are preserved unchanged. The string parser still rejects an integral float or true even though Laravel’s integer predicate accepts them. A Stringable object may produce an empty string after required has accepted the original object; required does not constrain the later parsed representation.

Parse::base64()

Accepts a non-empty native string in canonical standard Base64 and produces the represented bytes as non-empty-string. The output is a PHP binary string; it is not promised to be UTF-8 text. For example, 'aGk=' becomes 'hi', and 'AA==' becomes a one-byte string containing NUL.

The parser uses strict decoding and requires that encoding the decoded bytes reproduce the input exactly. It therefore rejects whitespace, the URL-safe alphabet, extra padding, and spellings such as 'aGk' that omit required = padding. Canonical input such as 'TWFu' needs no padding and remains valid. Empty input fails rather than producing an empty string.

Laravel added its preserving base64 predicate in 13.21. Parse::base64() performs its own validation and is available on every Laravel release supported by the parsing runtime. The native base64 rule is not required, and does not exist as a built-in on earlier releases.

Parse::boolean()

Accepts exactly Laravel’s strict boolean input set and produces bool:

true, 1, '1'   -> true
false, 0, '0'  -> false

Values such as 'true', 'false', 'on', 'off', floats, and blank strings fail. PHP truthiness is deliberately not used.

Parse::accepted()

Accepts exactly Laravel’s accepted token set and produces literal true:

'yes', 'on', '1', 1, true, 'true' -> true

Parse::declined()

Accepts exactly Laravel’s declined token set and produces literal false:

'no', 'off', '0', 0, false, 'false' -> false

These are separate parsers rather than a widened Parse::boolean() contract. They are also unconditional grammars: Laravel’s accepted_if and declined_if predicates may become inactive and leave an arbitrary original value untouched, so they do not imply a corresponding conditional parser.

Parse::enum()

Accepts an existing case of the configured backed enum or a value with exactly the enum’s native backing type. It produces the concrete enum case.

String-backed enums accept only strings; int-backed enums accept only ints. Unlike Laravel’s Rule::enum(), an int-backed enum does not accept the string '1', and a string-backed enum does not accept the integer 1 through PHP coercion.

Passing a pure enum throws InvalidArgumentException when the rule is constructed because it has no canonical wire representation. Name matching and only() / except() filters are not supported.

Parse::dateTime()

Produces DateTimeImmutable from Laravel-compatible date input by default, or from one or more exact PHP date formats:

Parse::dateTime()
Parse::dateTime(timezone: 'America/New_York')
Parse::dateTime('Y-m-d H:i:s')
Parse::dateTime(['Y-m-d', DateTimeInterface::ATOM])
Parse::dateTime('Y-m-d H:i:sP', 'America/New_York')
Parse::dateTime('Y-m-d', new DateTimeZone('UTC'))

With no format, acceptance follows Laravel’s ordinary date rule: PHP’s strtotime() must recognize the string or numeric value, date_parse() must describe a valid calendar date, and this parser must be able to construct a DateTimeImmutable from the same input. This deliberately includes Laravel’s broad grammar. For example, 2024-2-29, the integer 20240229, and 2024-02-29 +1 day pass; tomorrow alone and 2024-02-30 fail. Laravel validates and preserves those successful scalar representations; this parser turns them into an immutable date object. Embedded NUL bytes are rejected even on PHP versions where Laravel’s predicate accepts them: a parser must not let a hidden byte change the apparent date into a different date or timezone.

An explicit format selects strict mode. String input is parsed with DateTimeImmutable::createFromFormat(), with unspecified fields reset from the Unix epoch. Parsing must report no warnings or errors, and formatting the result with the same format must reproduce the input byte for byte. This rejects PHP’s ordinary date normalization, relative strtotime() expressions, trailing data, whitespace, and alternate spellings. For example, 2024-02-29 matches Y-m-d; 2024-02-30, 2024-2-29, and tomorrow do not. A non-empty list tries exact formats in declaration order and returns the first match.

The format is used both to parse and to reproduce the input. Unescaped createFromFormat()-only controls (!, |, +, *, ?, and #) are therefore rejected when the rule is constructed; ! reset semantics are already applied automatically. Escape one of those characters with \ only when it is intended as literal input. A dangling escape is also invalid.

UTC is the default output timezone for input that does not carry one. An explicit DateTimeZone or identifier string changes that fallback; an offset or timezone parsed from the input takes precedence. The configured timezone, not PHP’s process default, is used when constructing zone-less output in both modes. Default mode still uses strtotime() as Laravel’s compatibility gate. An @ timestamp fixes an instant but PHP otherwise forces its result to +00:00; this parser represents that instant in the configured timezone.

In strict mode, Unix-timestamp formats such as U and U.u retain the encoded instant but represent the resulting object in the configured timezone. Because a Unix timestamp already fixes the instant, combining U with an input timezone character such as e, O, P, p, or T is rejected at construction. A nonexistent local wall time during a daylight-saving transition fails because PHP’s normalized result does not round-trip exactly. Ambiguous local wall times follow the timezone database’s resolution; include an offset in the format when the instant itself must be unambiguous.

An existing DateTimeImmutable passes through unchanged. Another DateTimeInterface implementation is copied with DateTimeImmutable::createFromInterface(), preserving its instant and timezone. The configured formats and timezone constrain scalar input only. An invalid format, format list, or timezone is rejected when the rule is constructed.

Parse::timezone()

For required input, this produces DateTimeZone from the same string identifiers accepted by Laravel’s default timezone rule. Matching is exact and case-sensitive. An existing DateTimeZone passes through unchanged, including one constructed from a representation outside Laravel’s string grammar.

The parser deliberately does not accept every string understood by the DateTimeZone constructor. Numeric offsets such as +05:30, abbreviations such as EST, backward-compatible aliases such as US/Eastern, and the Etc/* group—including Etc/UTC and Etc/GMT+5—are constructor inputs but are not members of Laravel’s default timezone_identifiers_list(DateTimeZone::ALL) set. Accepting them would make the parser’s apparent relationship to Laravel’s rule false.

This parser does not expose Laravel’s group and country parameters. From Laravel 10.12 onward, an application that needs a subset can keep that predicate as an ordinary rule so it checks the original identifier before parsing:

'timezone' => ['required', 'timezone:per_country,US', Parse::timezone()]

Laravel 10.7 through 10.11 silently ignores those parameters and applies the default identifier set. On those releases, use an explicit membership rule:

use Illuminate\Validation\Rule;

$usTimezones = timezone_identifiers_list(DateTimeZone::PER_COUNTRY, 'US');
'timezone' => ['required', Rule::in($usTimezones), Parse::timezone()]

Unlike Laravel’s optional non-implicit timezone rule, the parser is implicit: a present blank or whitespace-only value is parsed and rejected rather than being skipped and preserved.

Presence and adjacent Laravel rules

A parser controls the value it produces, not whether the key must exist:

['age' => [Parse::integer()]]                       // array{age?: int}
['age' => ['required', Parse::integer()]]           // array{age: int}
['age' => ['nullable', Parse::integer()]]           // array{age?: int|null}

That separation also applies to accepted and declined tokens. Laravel’s built-in accepted and declined rules imply requiredness; their parsing counterparts do not. Use ['required', Parse::accepted()] or ['required', Parse::declined()] to preserve that presence behavior while normalizing the successful output.

Parsing rules are implicit, so a present blank string cannot bypass them. The value is passed to the parser and fails unless that parser’s grammar accepts it. In particular, a string-backed enum may legitimately declare '' as a backing value. nullable preserves a present null; it does not make other blank values nullable.

Ordinary Laravel rules always observe the original representation. Write-back happens only after they finish. This matters for Laravel’s size rules:

'age' => ['required', 'integer', Parse::integer(), 'min:18']

The integer predicate tells Laravel to compare min numerically. Without a named numeric rule, Laravel may compare the raw string by length even though validated() later contains an int. Rule order does not change that phase boundary.

PHPStan reports this combination as laravelValidation.parsingNumericSize when a numeric parser is paired with min, max, between, or size but the rule list has no integer, numeric, or decimal marker. For an integer-producing parser, add integer, numeric, or an appropriate decimal rule. For a float-producing parser, use numeric or an appropriate decimal rule; integer rejects non-integral values. If a custom numeric parser intentionally accepts values whose original string, array, or file representation should be measured, the diagnostic can be ignored by identifier at that site.

Which values each phase observes

LocationRepresentation
Ordinary Laravel rulesOriginal input
An after() callback registered before validationOriginal input
Successful validated() and safe() outputParsed values
The caller’s array or request inputOriginal input
FormRequest passedValidation() at runtimeParsed values

Do not read validated(), safe(), valid(), or getData() from an after() callback. Normal callbacks run before parser write-back and therefore observe the pre-parse state. PHPStan does not currently model this callback phase and may still expose the final parsed type there.

FormRequests

Parsing rules can be returned from FormRequest::rules() like any other rule. After successful validation, validated() and safe() contain parsed values, while all() and input() retain the request values.

Enable FormRequest inference if PHPStan should infer the parsed shape or union of discovered concrete contracts for conventional FormRequests, including supported direct safe() projections. If application code must consume the parsed values during the FormRequest lifecycle, passedValidation() is the runtime post-write-back hook. Declaring that hook currently makes the extension conservatively decline FormRequest inference for the class because the hook can mutate application state or replace the effective contract.

Lifecycle and soundness limits

  • A validator that completes parser write-back is single-use. passes(), fails(), and validate() each start a validation run and must not be called again on that validator. After one run, validated(), safe(), valid(), invalid(), and errors() reuse its result. Use fails() followed by validated(), not fails() followed by validate(); repeated validate() also fails. A first call to validated() may perform the one allowed run and preserves the inferred shape, whereas direct Validator::validate() currently retains Laravel’s broad array return type.
  • Parsing rules cannot be serialized or unserialized. Their validator-scoped lifecycle state is not transferable, and accepting deserialized properties could bypass the immutable implicit marker on which parsed types depend.
  • valid() on failed or short-circuited validation is not parsed output. It may contain raw attributes whose parsing rules Laravel never reached.
  • Executable custom rules and runtime validation extensions can mutate data outside the parser’s finalization contract. When they are combined with a parser and no usable static lifecycle contract exists, PHPStan returns mixed rather than promising the parser’s produced type.
  • Mutating an inferred validator through setData(), setValue(), setRules(), addRules(), or imperative sometimes() is subject to the diagnostics and widening described under Validator mutation.

Applications may define another parsing rule through the runtime API:

use jbboehr\Rensei\ParseFailure;
use jbboehr\Rensei\Rules\BaseParsingRule;

/** @extends BaseParsingRule<non-empty-string> */
final class NonEmptyStringRule extends BaseParsingRule
{
    public function parse(mixed $value): string
    {
        if (!is_string($value) || $value === '') {
            throw new ParseFailure();
        }

        return $value;
    }

    protected function message(): string
    {
        return 'The :attribute field must not be empty.';
    }
}

parse() returns the declared T or throws ParseFailure; message() is required for validation failures. Direct static inference deliberately requires a final concrete BaseParsingRule<T> subclass. A non-final parser can be extended with an implicit property that shadows the base class’s immutable marker, so PHPStan conservatively declines it. A final parser must not declare that property itself.

Application abstractions may intentionally expose only the generic parser contract. Adapt that value before putting it in a Laravel rule list:

use jbboehr\Rensei\Parse;
use jbboehr\Rensei\ValueParser;

/** @return ValueParser<int> */
function ageParser(): ValueParser
{
    return Parse::integer();
}

$rules = [
    'age' => ['required', Parse::using(ageParser())],
];

ValueParser<T> is a pure conversion contract; it does not require application code to implement Laravel’s validation callbacks. Parse::using() delegates only parse() to the supplied parser. Its final concrete adapter supplies the immutable implicit marker, single-use validator lifecycle, and delayed write-back that PHPStan cannot prove from the interface alone. The adapter retains T, so the example still infers age as int. Using an abstract Laravel-facing ParsingRule<T> directly remains conservative.