Repository navigation
v1.9.0
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-stringValidation 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 discardedValidator
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\UserFactorybuilder-of<Model>usesnewEloquentBuilder(),#[UseEloquentBuilder], then static$builder, falling back toBuilder<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>, optionallycollection-of<TKey, Model>likearray<K, V>, usesnewCollection(),#[CollectedBy], then static$collectionClass, including Laravel 13's inheritedCollectedBybehavior.factory-of<Model>followsModel::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/whereDoesntHaveRelationand every*Morphvariantwith,withOnly,load,loadMissing, includingload*on an Eloquent collectionwithAggregate/withCount/withMax/withMin/withSum/withAvg/withExistsand the matchingload*, withaccounts as totalaliases- nested eager-load arrays, constraint arrays, and column selectors (
accounts.transactions:id) - a model's
$withand$withCountdefaults, 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.
UnusedViewsRuleonly tracedview(),@include, and@extends, so every<x-alert>component was reported unused. View HTML is scanned for component tags and the short, prefixed, and.indexnamings 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
Contentview names are checked. Theview,html,text, andmarkdownconstructor arguments and fluent setters areview-string|null, so a missing Blade file failsargument.type.htmlStringis rendered HTML and stays a plain string. Mail::send()andMailer::send()view names are collected as usages too.
Eloquent
- Get-only attributes are writable again.
Attribute::get()leavesTSetasnever, 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 staysnever, 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'sTKeyinstead of widening to a benevolentint|string. Non-model items stay a Support collection. where()subquery callbacks get the right builder. Eloquent'swhere($callback, $operator, $value)forwards to the query builder, which invokes the closure with a Query Builder; a barewhere($callback)nests an Eloquent one. The callback is now typed from whether$operatoris null, matching Laravel's runtime check.orWhere()rewrites two-arg calls throughprepareValueAndOperatorfirst, and that arity is covered.- Unsigned integer casts keep their range. An
intcast on anunsignedIntegercolumn no longer widensint<0, max>toint. - Late static binding survives
Modelstatic 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 thanstatic(YourModel), so an override narrowing tostaticcould 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 becamemethod.notFound. Abstractness now only matters where the lookup genuinely fails. - Attribute-based scopes are reported by the forwarding rules.
#[Scope]methods were invisible tomodelForwardingToBuilderandmodelStaticForwardingToBuilder(both still off by default). #[UniqueFor]is found on parents and traits. Laravel'sReadsClassAttributeswalks the parent chain and each class's immediate traits;laravel.uniqueJob.missingUniqueFornow 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.9No configuration changes are required, and no new opt-in rules were added. Several things may report errors they previously missed:
- Relation existence covers far more call shapes, and it is always on. Eager loads, aggregates, collection
load*, and$with/$withCountdefaults are checked for the first time. Genuine typos will surface. - Mailable
Contentview names are checked, so aContent(view: 'mail.missing')now failsargument.type. - Validated data is a shape. Code that treated
validated()asarray<string, mixed>now has real types to disagree with — most commonly where a numeric string was assumed to have been cast.integerandnumericvalidate without casting; read through$request->integer()/->float()or cast at the boundary. - Unused views may stop being reported, since anonymous components,
Content, andMail::send()now count as usages. Existing baseline entries for those can go. - Narrower types from unsigned casts, paginator collections, serialization shapes,
parent::binding, and relation callbacks can unmatch old baseline entries in either direction.
New Contributors
- @samsonasik made their first contribution in #30
- @inmula made their first contribution in #31
- @pindab0ter made their first contribution in #33
Thanks also to @zfhassaan for #32.
Full Changelog: v1.8.0...v1.9.0