Skip to content

v1.9.0

Choose a tag to compare

@calebdw calebdw released this 25 Sep 03:53
· 43 commits to master since this release
626508b

v1.9.0 is the validation release. rules() is parsed into a real array shape, so validated(), safe(), $request->title, and Validator::make() all carry the fields you declared instead of array<string, mixed>. Alongside it: three new phpdoc types (builder-of, collection-of, factory-of), model serialization shapes, typed relationship query callbacks, and a much wider relation-existence check.

Validation shapes

rules() is a constant array in almost every request, and nothing was reading it. Now it is parsed — pipe strings, arrays of strings, Rule::enum() / new Enum(), Rule::in() / in:, min / max / between on integers, nested paths, wildcards, and files. Closures, Rule::when(), and $this->… in the array are skipped.

class StorePostRequest extends FormRequest
{
    public function rules(): array
    {
        return [
            'title'       => ['required', 'string'],
            'body'        => 'nullable|string',
            'age'         => 'integer',
            'status'      => ['required', Rule::enum(PostStatus::class)],
            'size'        => 'integer|min:1|max:10',
            'tags'        => 'array',
            'tags.*'      => 'string',
            'author.name' => 'required|string',
            'avatar'      => 'image',
        ];
    }
}

$post->validated();
// array{title: string, body?: string|null, age?: int|numeric-string,
//       status: 'draft'|'published', size?: int<1, 10>|numeric-string,
//       tags?: array<int|string, string>, author: array{name: string},
//       avatar?: Illuminate\Http\UploadedFile}

$post->safe(['title', 'body']);   // array{title: string, body?: string|null}
$post->title;                     // string
$post->age;                       // int|numeric-string

Validation checks, it does not cast. integer accepts '42' and hands back '42'. That is why age is int|numeric-string and not int — a form posts strings, JSON may already carry an int, and the rule allows both. integer(), boolean(), and enum() are the casts, and they still return what they always did.

A required field with no sometimes is a required key; everything else is optional. nullable adds null. A field excluded on one branch of a conditional is optional rather than absent.

$request->foo is typed like validated()['foo'] for keys named in rules(). Runtime __get reads all(), which includes unvalidated input and route parameters; the type is the useful shape rather than that bag.

Inline rules

The same parser drives $request->validate() and validateWithBag(). The return is the shape, and after the call $request is narrowed to Request & object{…}, the way BelongsToMany already intersects the pivot:

$data = $request->validate([
    'title' => ['required', 'string'],
    'age'   => 'integer',
]);
// array{title: string, age?: int|numeric-string}

$request->title;  // string, even when the return value is discarded

Validator

Validator::make(), validator(), the facade, and Illuminate\Validation\Factory carry the shape on Validator<TValidated>, so validated(), validate(), and safe() match what a form request gives you:

$validator = Validator::make($input, ['title' => 'required|string', 'age' => 'integer']);
// Illuminate\Validation\Validator<array{title: string, age?: int|numeric-string}>

$validator->validated();   // array{title: string, age?: int|numeric-string}

Dotted paths nest (author.name → author: array{name: string}). Numeric segments are integer keys, because PHP casts them that way (items.0.id → items: array{0: array{id: …}}). A wildcard group keeps whatever keys were submitted — array<int|string, …>, narrowed to a list only under Laravel's own list rule — because Laravel does not reindex them.

See the form requests guide.

New phpdoc types

Three types that resolve Laravel's per-model classes, each following Laravel's own precedence and each walking unions member by member.

/** @param builder-of<Post> $query */          // App\PostBuilder<App\Post>
/** @param collection-of<Account> $accounts */ // App\AccountCollection<int|string, App\Account>
/** @param factory-of<User> $factory */        // Database\Factories\UserFactory
  • builder-of<Model> uses newEloquentBuilder(), #[UseEloquentBuilder], then static $builder, falling back to Builder<Model>. Generic arguments, static, $this, and intersections survive. Model query methods, Collection::toQuery(), and relation query builders are stubbed with it, so a custom builder is no longer flattened on the way through.
  • collection-of<Model>, optionally collection-of<TKey, Model> like array<K, V>, uses newCollection(), #[CollectedBy], then static $collectionClass, including Laravel 13's inherited CollectedBy behavior.
  • factory-of<Model> follows Model::factory(): newFactory(), static $factory, #[UseFactory], then the naming convention.

A second argument to builder-of selects a relation, which is what types relationship callbacks:

/** @param builder-of<User, 'posts.comments'> $query */   // Builder<Comment>

See custom types.

Relationship query callbacks

whereHas, whereRelation, with, withWhereHas, and the morph variants type their closure as the related model's builder, custom builders included. A relation object argument works as well as a name, dotted paths resolve to the final model, and a union of names gives a union of builders:

User::query()->whereHas('posts', function ($query) {
    // App\PostBuilder<App\Post>
});

User::query()->whereHas('posts.comments', function ($query) {
    // Illuminate\Database\Eloquent\Builder<App\Comment>
});

Comment::query()->whereHasMorph('commentable', [Post::class, Account::class], function ($query) {
    // App\PostBuilder<App\Post>|Illuminate\Database\Eloquent\Builder<App\Account>
});

An undocumented relation — one whose return type carries no generics — gives Builder<Model> rather than pretending the relation still targets the declaring model.

Relation existence

The always-on laravel.relationExistence check grew from 11 methods to the full set, and now reads shapes it previously ignored. This will report relations it used to miss.

  • has / doesntHave / whereHas / whereDoesntHave / whereRelation / whereDoesntHaveRelation and every *Morph variant
  • with, withOnly, load, loadMissing, including load* on an Eloquent collection
  • withAggregate / withCount / withMax / withMin / withSum / withAvg / withExists and the matching load*, with accounts as total aliases
  • nested eager-load arrays, constraint arrays, and column selectors (accounts.transactions:id)
  • a model's $with and $withCount defaults, read off concrete models without constructing them
$user->load(['posts' => fn ($q) => $q->latest(), 'bogus']);   // 'bogus' reported
User::query()->withCount('accounts as total');                // alias understood

class Post extends Model
{
    protected $with = ['auther'];   // reported
}

Model serialization

toArray() and attributesToArray() are typed from the model's columns and $appends, minus $hidden and filtered by $visible, with date, enum, Arrayable, and class-cast serialize() handling applied:

$post->toArray();
// array{id?: int, title?: string, published_at?: string|null, ...<string, mixed>}

Keys are optional because a partial select may not have loaded them, and the trailing ...<string, mixed> leaves the shape open, so aggregates, loaded relations, and anything else the model is carrying stay reachable. A model that overrides toArray, attributesToArray, getAppends, getHidden, or getVisible stays array<string, mixed>. Unions are walked, so User&Marker no longer collapses to mixed.

Views

  • Anonymous Blade components count as usages. UnusedViewsRule only traced view(), @include, and @extends, so every <x-alert> component was reported unused. View HTML is scanned for component tags and the short, prefixed, and .index namings are all marked. <x-dynamic-component> is ignored, and views with no <x- are skipped before the regex runs. Adapted from larastan/larastan#2519.
  • Mailable Content view names are checked. The view, html, text, and markdown constructor arguments and fluent setters are view-string|null, so a missing Blade file fails argument.type. htmlString is rendered HTML and stays a plain string.
  • Mail::send() and Mailer::send() view names are collected as usages too.

Eloquent

  • Get-only attributes are writable again. Attribute::get() leaves TSet as never, which describes the hook, not the property — Laravel still stores a raw assignment on a database-backed attribute. Writes now fall back to the column's type. A computed property with no column stays never, so those writes are still rejected. This is the mirror of the set-only fix in v1.8.0.
  • Paginators keep the model's collection. getCollection() on length-aware, simple, and cursor paginators resolves to a custom collection class, and keys stay the paginator's TKey instead of widening to a benevolent int|string. Non-model items stay a Support collection.
  • where() subquery callbacks get the right builder. Eloquent's where($callback, $operator, $value) forwards to the query builder, which invokes the closure with a Query Builder; a bare where($callback) nests an Eloquent one. The callback is now typed from whether $operator is null, matching Laravel's runtime check. orWhere() rewrites two-arg calls through prepareValueAndOperator first, and that arity is covered.
  • Unsigned integer casts keep their range. An int cast on an unsignedInteger column no longer widens int<0, max> to int.
  • Late static binding survives Model static calls. The static-method extension claims every native Model method, and for the ones it had nothing to say about it handed back a return type resolved off the declaring class — discarding PHPStan's own binding. parent::replicate() came back as $this(Model) rather than static(YourModel), so an override narrowing to static could not satisfy its own signature. It now returns null when it is not contributing a builder or a collection. parent:: forwards binding at runtime but resolves to the parent class, so it answers with the calling scope instead.
  • Relations on abstract models resolve. Relation lookup bailed on any abstract model before asking whether the relation was declared, so a relation on an abstract base — or on a trait required into one — fell back to Builder<Model> and every custom builder method reached through it became method.notFound. Abstractness now only matters where the lookup genuinely fails.
  • Attribute-based scopes are reported by the forwarding rules. #[Scope] methods were invisible to modelForwardingToBuilder and modelStaticForwardingToBuilder (both still off by default).
  • #[UniqueFor] is found on parents and traits. Laravel's ReadsClassAttributes walks the parent chain and each class's immediate traits; laravel.uniqueJob.missingUniqueFor now matches that, and recognizes the attribute in the first place (#31 by @inmula). CollectedBy, UseFactory, UseEloquentBuilder, and #[Scope] were each walking native reflection separately; that is one shared path now.
  • No more internal error on an unloadable model. newInstanceWithoutConstructor() throws when a model implements a missing interface, which surfaced as a PHPStan crash instead of the ordinary missing-interface diagnostic.

Collections

concat, pad, and zip dropped the array shape when the added values matched the collection's own shape (#33 by @pindab0ter):

/** @param Collection<int, array{id: int, name: string}> $a */
$a->concat([['id' => 1, 'name' => 'x']]);
// was: Collection<int, non-empty-array<string, int|string>>
// now: Collection<int, array{id: int, name: string}>

Added values are widened so literals do not leak into the result, but GeneralizePrecision::lessSpecific() widened the keys too, turning the shape into a plain array that then absorbed the receiver's. templateArgument() precision widens only the values, which is what PHPStan itself uses to infer template arguments — so concat now agrees with collect(). Refined types in the added values still widen (non-empty-string → string); that is left for a follow-up.

Cache

Type coverage and a guide for Cache::remember(), rememberForever(), flexible(), sear(), and rememberWithWarmth(), plus the cache() helper and the contract — the closure's return type is the value type (#32 by @zfhassaan). See the cache guide.

Performance

The model Attribute type and helper return types are memoized, and Blade HTML is cheap-checked for <x- before the component regex runs.

Internal

StructArmed enforces layering, PSR-4, and final-by-default in QA (#30 by @samsonasik). FormRequestHelper is now ValidationHelper, since it is no longer form-request specific. Class attribute resolution moved onto ReflectionHelper, relation existence onto RelationExistenceHelper, factory resolution onto FactoryHelper, and orWhere closure handling onto OrWhereClosureHelper. PHPStan dev's control-flow diagnostics are clean. CI skips redundant dependency work, and a glob assertion no longer depends on filesystem traversal order.

Upgrade

composer require --dev calebdw/phpstan-laravel:^1.9

No configuration changes are required, and no new opt-in rules were added. Several things may report errors they previously missed:

  1. Relation existence covers far more call shapes, and it is always on. Eager loads, aggregates, collection load*, and $with / $withCount defaults are checked for the first time. Genuine typos will surface.
  2. Mailable Content view names are checked, so a Content(view: 'mail.missing') now fails argument.type.
  3. Validated data is a shape. Code that treated validated() as array<string, mixed> now has real types to disagree with — most commonly where a numeric string was assumed to have been cast. integer and numeric validate without casting; read through $request->integer() / ->float() or cast at the boundary.
  4. Unused views may stop being reported, since anonymous components, Content, and Mail::send() now count as usages. Existing baseline entries for those can go.
  5. Narrower types from unsigned casts, paginator collections, serialization shapes, parent:: binding, and relation callbacks can unmatch old baseline entries in either direction.

New Contributors

Thanks also to @zfhassaan for #32.

Full Changelog: v1.8.0...v1.9.0