psalm/plugin-laravel
Psalm plugin for Laravel
v4.15.6
5.5M downloads/mo
#2317 most downloaded on Packagist
psalm/psalm-plugin-laravel
What this package is like to depend on
Last release 2 days ago
21 Aug 2026
Ships fairly regularly
a new release about every 9 days
Rarely documented
notes for 10 of 169 stable releases
Nothing withdrawn
no release was ever pulled
8 years old
177 releases · first in 2019
104 releases in the last 12 months
see the full history below
Release timeline
174 releases · Feb 2019 to Aug 2026Releases
latest 60 of 177-
v4.15.621 Aug 2026Release notes
Open source →Two new inference sources for Eloquent-backed code, one new opt-in queue-safety rule, and three false-positive fixes across migrations, model attribute helpers, and pipelines.
Features
- Add opt-in
SerializedQueuedModelrule forShouldQueueclasses that hold an Eloquent model without reachingIlluminate\Queue\SerializesModels, where the whole model is written into the queue payload instead of aModelIdentifier(#1385). Enable with<findSerializedQueuedModels value="true" />. Detection resolves the flattened__serialize()/__sleep()rather than matching the trait name, so framework bases that already pull the trait in (Illuminate\Foundation\Queue\Queueable,Illuminate\Notifications\Notification) and classes that hand-write their own serialization stay silent.
final class ReconcileLedger implements ShouldQueue { public function __construct(private Customer $customer) {} - // silent: the entire Customer row is written into the queue payload + // SerializedQueuedModel: $customer will be serialized whole into the queue payload }- Infer
Arr::pluck()value and key types from the element model's@propertyannotations, matching whatCollection::pluck()andBuilder::pluck()already do (#1383).
/** @param list<Customer> $rows */ // Customer has @property string $id -Arr::pluck($rows, 'id'); // array<array-key, mixed> +Arr::pluck($rows, 'id'); // list<string>
Fixes
- Support
$schema = Schema::connection(...)in migrations, so columns declared through a variable-held builder (#1382). - Narrow
Model::getAppends()andModel::getMutatedAttributes()tolist<string>, so callers no longer have to re-assert the element type (#1381). - Type
Pipeline::then()from its destination closure instead of leaving itmixed(#1376).
Full Changelog: v4.15.5...v4.15.6
- Add opt-in
-
v4.15.518 Aug 2026Release notes
Open source →Taint precision and Eloquent type narrowing. Two taint fixes remove a duplicate finding and close a missed SQL injection, and four type fixes sharpen inference on facades, collections, and the query builder.
Fixes
- Restore plugin facade stub precedence over Laravel's generated
@methodtags, so templated facade methods resolve their real return type (#1370)
$value = Cache::remember('key', 60, fn (): User => User::first()); -// mixed — the generated @method tag outranked the plugin's stub +// User- Narrow
groupBy()andkeyBy()keys for model columns instead of widening toarray-key(#1369)
$byId = User::all()->keyBy('id'); -// Collection<array-key, User> +// Collection<int, User>- Type the
chunkById()callback likechunk(), so the chunk is a templated collection rather thanmixed(#1373)
Article::query()->chunkById(100, function ($chunk) { ... }); -// $chunk: mixed +// $chunk: Collection<int, Article>- Accept Laravel 13's fourth
$fetchUsingargument onDB::select(),DB::selectResultSets(),DB::cursor(), and theirConnectioncounterparts, while keeping the three-argument signature an error on Laravel 12 (#1374)
DB::cursor('select * from users', [], true, [PDO::FETCH_ASSOC]); -// TooManyArguments — the stub declared three parameters +// accepted on Laravel 13; still TooManyArguments on Laravel 12Row types follow the fetch mode, so a custom
$fetchUsingno longer claimsstdClass:$rows = DB::cursor('select 1'); // Generator<int, stdClass> $assoc = DB::cursor('select 1', [], true, [PDO::FETCH_ASSOC]); -// Generator<int, stdClass> +// Generator<int, mixed>- 🛡️ Report a tainted view name as
TaintedIncludeonly, not alsoTaintedFile(#1358). A view name selects which template executes; it cannot reach an arbitrary path, because the view finder rewrites.to/and appends a fixed extension - 🛡️ Key
WhereColumnTaintHandler's removal bridge on the AST node itself rather thanspl_object_id, closing a missed SQL injection (#1366). Psalm frees foreign ASTs mid-file, so a reissued object handle could hit a stale record and stripsqltaint from an unrelated expression - Ship the Psalm cache in the generated CI template, pass both thread flags, and install igbinary (#1347)
Full Changelog: v4.15.4...v4.15.5
- Restore plugin facade stub precedence over Laravel's generated
-
v4.15.416 Aug 2026Release notes
Open source →Extends taint reporting to the call forms Laravel applications actually write — facade statics,
response()on its contract, andview()names — and clears the false positives that surfaced alongside it.Features
- 🛡️ Report taint sinks on facade static calls,
response()contract methods, andview()names (#1318). A facade's surface is@methodpseudo-methods resolved through__callStatic, whose parameters have no docblock to carry a sink, so the static form was silent while the chained form fired.
Redirect::to($request->input('next')); -// silent: the facade pseudo-parameter carried no sink +// TaintedHeader: Detected tainted header- Detect undefined relations in model eager-load defaults
$withand$withCount(#1321). Supports dotted paths, column selectors, and the$withCountalias grammar; defers when an intermediate related model cannot be resolved.
class Post extends Model { protected $with = ['auther']; - // silent: eager-load defaults were never validated + // UndefinedModelRelation: relation 'auther' is not defined on Post }Fixes
- Honour
@psalm-taint-escapeon closure validation rules (#1352), in inlinevalidate()arrays, in FormRequestrules(), and when the closure is the field's whole rule. Previously the only way to assert a rule made a value safe was to extract it into a dedicated Rule class.
$request->validate([ 'path' => ['required', /** @psalm-taint-escape file */ static fn ($attr, $value, $fail) => /* ... */], ]); -// TaintedFile: a closure body is opaque, so no rule could assert safety +// clean- Fix
TaintedSqlfalse positives onwhere()array values for nullable, template-bounded, and intersection builder receivers (#1338, #1350). Values in the map form are PDO-bound, but a receiver typedBuilder|null,@template T of Builder, orT&Builderdeclined the strip.
/** @param Builder|null $query */ $query->where(['status' => $request->input('status')]); -// TaintedSql: Detected tainted SQL +// clean- Stop reporting
TaintedFileon uploaded-file extensions (#1324, #1325).getClientOriginalExtension()is the tail after the final dot of a normalized basename and cannot introduce a path segment, andclientExtension()returns a value from Symfony's MIME registry rather than raw client input. All other taint kinds, includinginclude, are unchanged.
Storage::putFileAs('uploads', $file, Str::ulid() . '.' . $file->getClientOriginalExtension()); -// TaintedFile: Detected tainted file handling +// clean-
Suppress cross-class taint flow through the
Dispatchabletraits (#1334). Psalm conflated taint nodes from the shared trait bodies, so an argument dispatched to one job appeared to reach an unrelated job's constructor sink. Genuine Bus and Event taint is still reported. -
Fingerprint the migration schema cache on file contents instead of modification times (#1346). A
git clonestamps every file with the checkout time, so the fingerprint changed on every CI run and the cache never hit even when the restored schema was still valid.
Full Changelog: v4.15.3...v4.15.4
- 🛡️ Report taint sinks on facade static calls,
-
v4.15.324 Jul 2026Release notes
Open source →Taint precision for
where()array forms and redirect responses.Fixes
- Fix
TaintedSqlfalse positives onwhere([[...]])nested-condition arrays: a static receiver spelling (Model::where(...)) skipped the strip entirely, and the inner condition value was scalar-gated so anymixed-typed request value (the common case) kept the sink. Also closes a false negative where a Model subclass with its own concretewhere()override had its real sink stripped (#1314).
$term = (string) $request->input('term'); -User::where([['name', '=', $term]])->get(); -// TaintedSql (false positive) -- static receiver wasn't recognized as PDO-bound User::where([['code', '=', $request->keyword]])->first(); -// TaintedSql (false positive) -- mixed-typed value failed the scalar gate +// clean -- both forms are PDO-bound, matching the instance-receiver behavior- Fix
TaintedSSRFincorrectly firing alongsideTaintedHeaderonredirect()and theRedirector/ResponseFactoryredirect family. A redirect sets aLocationheader for the browser to follow; the server makes no outbound request, so it's an open-redirect/header-injection sink, not SSRF (#1315).
-return redirect($request->url()); -// TaintedSSRF + TaintedHeader (SSRF was mislabelled) +return redirect($request->url()); +// TaintedHeader only
Full Changelog: v4.15.2...v4.15.3
- Fix
-
v4.15.223 Jul 2026Release notes
Open source →Taint-analysis precision for the query
where()family. Resolves theTaintedSqlinconsistency from #1300 (the same safe query reported or stayed silent depending on how it was written), and closes two SQL source/sink gaps.Features 🛡️
- Source
Request::__get()as user input, so$request->termcarries taint like$request->input('term')(#1305).
DB::table('t')->whereRaw((string) $request->input('term')); // TaintedSql -DB::table('t')->whereRaw((string) $request->term); // silent +DB::table('t')->whereRaw((string) $request->term); // TaintedSql- Add SQL taint sinks to
whereColumn()/orWhereColumn()on all three identifier positions, which the grammar emits raw (#1308).
-$builder->whereColumn((string) $request->input('c'), '=', 'other'); // silent +$builder->whereColumn((string) $request->input('c'), '=', 'other'); // TaintedSql
- Gate the whole-argument
where()sql-taint strip on a Laravel builder receiver, so a non-builderwhere(array $parts)that interpolates raw SQL keeps its report (#1311).
Fixes
- Fix
where()array forms raising a falseTaintedSqlon PDO-bound value positions; the strip now walks the array literal element-wise and keeps the sink only on raw-identifier positions (#1302, fixes #1300).
$term = (string) $request->input('term'); -Model::where([['name', 'LIKE', "%{$term}%"]]); // TaintedSql (false positive) +Model::where([['name', 'LIKE', "%{$term}%"]]); // clean — value is PDO-bound- Widen the
whereLike-family$valueparam tomixed, matchingwhere()and the PDO-bound runtime, so idiomatic calls stop reporting false positives (#1312).
-Model::whereLike('name', $request->query('q')); // PossiblyInvalidArgument +Model::whereLike('name', $request->query('q')); // clean
Full Changelog: v4.15.1...v4.15.2
- Source
-
v4.15.117 Jul 2026Nothing published for this version
-
v4.15.016 Jul 2026Nothing published for this version
-
v4.14.1212 Jul 2026Nothing published for this version
-
v4.14.1108 Jul 2026Nothing published for this version
-
v4.14.1008 Jul 2026Nothing published for this version
-
v4.14.902 Jul 2026Nothing published for this version
-
v4.14.801 Jul 2026Nothing published for this version
-
v4.14.730 Jun 2026Nothing published for this version
-
v4.14.629 Jun 2026Nothing published for this version
-
v4.14.525 Jun 2026Nothing published for this version
-
v4.14.423 Jun 2026Nothing published for this version
-
v4.14.221 Jun 2026Nothing published for this version
-
v4.14.121 Jun 2026Nothing published for this version
-
v4.14.018 Jun 2026Nothing published for this version
-
v4.13.215 Jun 2026Nothing published for this version
-
v4.13.112 Jun 2026Nothing published for this version
-
v4.13.012 Jun 2026Nothing published for this version
-
v4.12.408 Jun 2026Nothing published for this version
-
v4.12.231 May 2026Nothing published for this version
-
v4.12.126 May 2026Nothing published for this version
-
v4.12.023 May 2026Nothing published for this version
-
v4.11.023 May 2026Nothing published for this version
-
v4.10.215 May 2026Nothing published for this version
-
v4.10.111 May 2026Nothing published for this version
-
v4.10.010 May 2026Nothing published for this version
-
v4.9.307 May 2026Nothing published for this version
-
v4.9.204 May 2026Nothing published for this version
-
v4.9.127 Apr 2026Nothing published for this version
-
v4.9.026 Apr 2026Nothing published for this version
-
v4.8.418 Apr 2026Nothing published for this version
-
v4.8.317 Apr 2026Nothing published for this version
-
v4.8.217 Apr 2026Nothing published for this version
-
v4.8.116 Apr 2026Nothing published for this version
-
v4.8.015 Apr 2026Nothing published for this version
-
v4.7.012 Apr 2026Nothing published for this version
-
v4.6.207 Apr 2026Nothing published for this version
-
v4.6.105 Apr 2026Nothing published for this version
-
v4.6.005 Apr 2026Nothing published for this version
-
v4.5.030 Mar 2026Nothing published for this version
-
v4.4.027 Mar 2026Nothing published for this version
-
v4.3.225 Mar 2026Nothing published for this version
-
v4.3.125 Mar 2026Nothing published for this version
-
v4.3.024 Mar 2026Nothing published for this version
-
v4.2.023 Mar 2026Nothing published for this version
-
v4.1.022 Mar 2026Nothing published for this version
-
v4.0.122 Mar 2026Nothing published for this version
-
v4.0.018 Mar 2026Nothing published for this version
-
v4.0.0-rc.218 Mar 2026 pre-releaseNothing published for this version
-
v4.0.0-rc.117 Mar 2026 pre-releaseNothing published for this version
-
v4.0.0-beta.217 Mar 2026 pre-releaseNothing published for this version
-
v4.0.0-beta.116 Mar 2026 pre-releaseNothing published for this version
-
v3.15.621 Aug 2026Release notes
Open source →Two new inference sources for Eloquent-backed code, one new opt-in queue-safety rule, and three false-positive fixes across migrations, model attribute helpers, and pipelines.
Features
- Add opt-in
SerializedQueuedModelrule forShouldQueueclasses that hold an Eloquent model without reachingIlluminate\Queue\SerializesModels, where the whole model is written into the queue payload instead of aModelIdentifier(#1385). Enable with<findSerializedQueuedModels value="true" />. Detection resolves the flattened__serialize()/__sleep()rather than matching the trait name, so framework bases that already pull the trait in (Illuminate\Foundation\Queue\Queueable,Illuminate\Notifications\Notification) and classes that hand-write their own serialization stay silent.
final class ReconcileLedger implements ShouldQueue { public function __construct(private Customer $customer) {} - // silent: the entire Customer row is written into the queue payload + // SerializedQueuedModel: $customer will be serialized whole into the queue payload }- Infer
Arr::pluck()value and key types from the element model's@propertyannotations, matching whatCollection::pluck()andBuilder::pluck()already do (#1383).
/** @param list<Customer> $rows */ // Customer has @property string $id -Arr::pluck($rows, 'id'); // array<array-key, mixed> +Arr::pluck($rows, 'id'); // list<string> -Arr::pluck($rows, 'id', 'id'); // array<array-key, mixed> +Arr::pluck($rows, 'id', 'id'); // array<string, string>
Fixes
- Support
$schema = Schema::connection(...)in migrations, so columns declared through a variable-held builder (#1382). - Narrow
Model::getAppends()andModel::getMutatedAttributes()tolist<string>, so callers no longer have to re-assert the element type (#1381). - Type
Pipeline::then()from its destination closure instead of leaving itmixed(#1376).
Full Changelog: v3.15.5...v3.15.6
- Add opt-in
-
v3.15.518 Aug 2026Release notes
Open source →Taint precision and Eloquent type narrowing. Two taint fixes remove a duplicate finding and close a missed SQL injection, and four type fixes sharpen inference on facades, collections, and the query builder.
Fixes
- Restore plugin facade stub precedence over Laravel's generated
@methodtags, so templated facade methods resolve their real return type (#1370)
$value = Cache::remember('key', 60, fn (): User => User::first()); -// mixed — the generated @method tag outranked the plugin's stub +// User- Narrow
groupBy()andkeyBy()keys for model columns instead of widening toarray-key(#1369)
$byId = User::all()->keyBy('id'); -// Collection<array-key, User> +// Collection<int, User>- Type the
chunkById()callback likechunk(), so the chunk is a templated collection rather thanmixed(#1373)
Article::query()->chunkById(100, function ($chunk) { ... }); -// $chunk: mixed +// $chunk: Collection<int, Article>- Accept Laravel 13's fourth
$fetchUsingargument onDB::select(),DB::selectResultSets(),DB::cursor(), and theirConnectioncounterparts, while keeping the three-argument signature an error on Laravel 12 (#1374)
DB::cursor('select * from users', [], true, [PDO::FETCH_ASSOC]); -// TooManyArguments — the stub declared three parameters +// accepted on Laravel 13; still TooManyArguments on Laravel 12Row types follow the fetch mode, so a custom
$fetchUsingno longer claimsstdClass:$rows = DB::cursor('select 1'); // Generator<int, stdClass> $assoc = DB::cursor('select 1', [], true, [PDO::FETCH_ASSOC]); -// Generator<int, stdClass> +// Generator<int, mixed>- 🛡️ Report a tainted view name as
TaintedIncludeonly, not alsoTaintedFile(#1358). A view name selects which template executes; it cannot reach an arbitrary path, because the view finder rewrites.to/and appends a fixed extension - 🛡️ Key
WhereColumnTaintHandler's removal bridge on the AST node itself rather thanspl_object_id, closing a missed SQL injection (#1366). Psalm frees foreign ASTs mid-file, so a reissued object handle could hit a stale record and stripsqltaint from an unrelated expression - Ship the Psalm cache in the generated CI template, pass both thread flags, and install igbinary (#1347)
Full Changelog: v3.15.4...v3.15.5
- Restore plugin facade stub precedence over Laravel's generated
-
v3.15.416 Aug 2026Release notes
Open source →Extends taint reporting to the call forms Laravel applications actually write — facade statics,
response()on its contract, andview()names — and clears the false positives that surfaced alongside it.Features
- 🛡️ Report taint sinks on facade static calls,
response()contract methods, andview()names (#1318). A facade's surface is@methodpseudo-methods resolved through__callStatic, whose parameters have no docblock to carry a sink, so the static form was silent while the chained form fired.
Redirect::to($request->input('next')); -// silent: the facade pseudo-parameter carried no sink +// TaintedHeader: Detected tainted header- Detect undefined relations in model eager-load defaults
$withand$withCount(#1321). Supports dotted paths, column selectors, and the$withCountalias grammar; defers when an intermediate related model cannot be resolved.
class Post extends Model { protected $with = ['auther']; - // silent: eager-load defaults were never validated + // UndefinedModelRelation: relation 'auther' is not defined on Post }Fixes
- Honour
@psalm-taint-escapeon closure validation rules (#1352), in inlinevalidate()arrays, in FormRequestrules(), and when the closure is the field's whole rule. Previously the only way to assert a rule made a value safe was to extract it into a dedicated Rule class.
$request->validate([ 'path' => ['required', /** @psalm-taint-escape file */ static fn ($attr, $value, $fail) => /* ... */], ]); -// TaintedFile: a closure body is opaque, so no rule could assert safety +// clean- Fix
TaintedSqlfalse positives onwhere()array values for nullable, template-bounded, and intersection builder receivers (#1338, #1350). Values in the map form are PDO-bound, but a receiver typedBuilder|null,@template T of Builder, orT&Builderdeclined the strip.
/** @param Builder|null $query */ $query->where(['status' => $request->input('status')]); -// TaintedSql: Detected tainted SQL +// clean- Stop reporting
TaintedFileon uploaded-file extensions (#1324, #1325).getClientOriginalExtension()is the tail after the final dot of a normalized basename and cannot introduce a path segment, andclientExtension()returns a value from Symfony's MIME registry rather than raw client input. All other taint kinds, includinginclude, are unchanged.
Storage::putFileAs('uploads', $file, Str::ulid() . '.' . $file->getClientOriginalExtension()); -// TaintedFile: Detected tainted file handling +// clean-
Suppress cross-class taint flow through the
Dispatchabletraits (#1334). Psalm conflated taint nodes from the shared trait bodies, so an argument dispatched to one job appeared to reach an unrelated job's constructor sink. Genuine Bus and Event taint is still reported. -
Fingerprint the migration schema cache on file contents instead of modification times (#1346). A
git clonestamps every file with the checkout time, so the fingerprint changed on every CI run and the cache never hit even when the restored schema was still valid.
Full Changelog: v3.15.3...v3.15.4
- 🛡️ Report taint sinks on facade static calls,
-
v3.15.325 Jul 2026Release notes
Open source →Taint-analysis precision for static
where()/whereNot()calls and redirect responses (Psalm 6 line), ported from the paired v4.15.3 release.Fixes
- Fix
TaintedSqlfalse positives and a false negative onwhere()/whereNot()static receivers and nested-condition array values (#1300).
class ConcreteOverrideModel extends Model { /** @psalm-taint-sink sql $column */ public static function where(mixed $column): void {} } $term = (string) $request->input('term'); -ConcreteOverrideModel::where([['name', '=', $term]]); // silent (false negative, own sink stripped) +ConcreteOverrideModel::where([['name', '=', $term]]); // TaintedSql Article::where([['name', '=', $request->keyword]]); -// TaintedSql (false positive) -- mixed value failed the outer position's scalar gate +// clean -- inner nested-condition value strips unconditionally on a real Model receiver- Fix
TaintedSSRFfalse positive onredirect()andRedirect/ResponseFactoryredirect responses. A redirect is a header/open-redirect sink, not a server-side request, so it now reports the correct sink kind (#1313).
-return redirect($request->input('next')); // TaintedSSRF (wrong sink kind) +return redirect($request->input('next')); // TaintedHeader
Full Changelog: v3.15.2...v3.15.3
- Fix