v4.18.0 brings Psalm to your Blade templates: broken references, wrong types and XSS taint in every *.blade.php, reported at the template's own file and line. It also cuts a large share of Eloquent noise on PHP code.
Requires Laravel 12.27+ or 13.20+ and Psalm 7.0.0-rc16+ (details below).
Highlights
1. Blade template analysis
Opt in with one line in psalm.xml (or per run with psalm-laravel analyze --blade):
<pluginClass class="Psalm\LaravelPlugin\Plugin">
<blade />
</pluginClass>or alternatively using cli option --blade:
./vendor/bin/psalm-laravel --bladeEvery template is analyzed as PHP under your own errorLevel, <issueHandlers>, {{-- @psalm-suppress --}} comments and baseline. A renamed enum case, a removed constant or a typo in a property name now fails the run instead of the page.
2. 🛡️ XSS taint follows your data into templates
Psalm's taint analysis now crosses the view() boundary. Request input passed as view data and echoed raw with {!! !!} reports TaintedHtml at the template line, with the full path from the controller. {{ }} and @json() count as escaped. Taint also flows through class component properties, @include scope and anonymous components (#2036, #2046, #2047, #2106).
3. blade:annotate writes the view contracts for you
vendor/bin/psalm-laravel blade:annotate reads the view() calls and include directives that render each template and writes the matching @var declarations (--dry-run prints the diff only). With declarations in place, view data is type checked as in PHP.
4. Less noise on Eloquent code
Dynamic where{Column}() calls resolve from migrations, relation callbacks get the related model's builder, and Builder passthru methods resolve on relations. With Blade analysis off (the default), the net change against v4.17.1 on 18 open-source apps is −4,102 issues.
Guide: docs/blade.md. Found a false positive in a template? Please open an issue with the template snippet.
Checked without any With a View data checks are experimental: Apps using livewire/blaze: templates are compiled with Blaze switched off, so Eloquent:
Collections and support:
Facades and aliases:
Integrations:
🌕 All changes
Requirements
conflict on laravel/framework (#1922, #1995, #2144, #2145)
illuminate/* packages and orchestra/testbench-core: installing the plugin no longer pulls Laravel or Testbench into a project without Laravel. Analyzing a Laravel package (no bootstrap/app.php) needs orchestra/testbench in its dev dependencies, as in 4.17.4 (#2141, #2144)
Blade template analysis
@var:
route(), auth(), Str::), class components, @props defaults
UnregisteredRouteName, MissingView on @include, MissingTranslation
{!! request('q') !!} reports TaintedHtml; {{ }} and @json() are escaped) and taint carried in from view() data, class component properties, @include scope and anonymous components (#2036, #2046, #2047, #2106)
return view('search', ['q' => $request->input('q')]);
{{-- search.blade.php --}}
{!! $q !!}
+// TaintedHtml (was not reported)@var declaration, the view data is type checked as in PHP. Mixed* issues are never reported in templates. A template that fails to compile is reported as BladeCompileError.
MissingViewData, InvalidViewData and UnusedViewData compare view(), Mailable, MailMessage and @includeIsolated call sites with the template's declarations. They report at info by default; enable <experimental value="true" /> to try them as errors.
blade:annotate skips a declaration that a view composer, a first() lookup or a surrounding condition contradicts (#2122). Known limits, such as a cacheDir shared by concurrent runs, are listed in the guide (#2120). Shadow compilation and template suppressions handle more edge cases (#2121).
<x-...> tags are analyzed as standard Blade instead of Blaze's generated code (removes UnresolvableInclude, MissingParamType and PossiblyFalseArgument noise on every tag line), including when BLAZE_DEBUG is on (#2124, #2125).
Features
psalm-laravel analyze --plugin-option=KEY=VALUE to override a plugin setting for one run (--plugin-option=experimental=true). The command line wins over psalm.xml (#1709)
__invoke, middleware handle()/terminate(), queued job handle()/failed() and the queue hooks they declare. Removes UnusedMethod/UnusedProperty cascades behind invokable controllers, middleware and jobs (#1781)
app(), make() and $app[...] over unions of string literals (#1802)
app($flag ? CacheManager::class : DatabaseManager::class);
-// mixed
+// CacheManager|DatabaseManager
Manager::driver() as the union of create*Driver() return types when the driver name is not a literal, such as a config-driven default (#1743)
withPivot() and withTimestamps() columns in BelongsToMany pivot shapes, using() custom pivots included (#1951, #1992, #2008, #2094)
whereHas() and the other relation-query callbacks as the related model's builder, custom builders included (#1791, #2110)
Customer::query()->whereHas('vehicles', fn ($q) => $q->whereElectric());
-// MixedMethodCall
+// $q is VehicleBuilderCollection::tap(), pipe(), chunkWhile() and the whenEmpty() family (#1901, #2100)
🛡️ Taint analysis
echo/print output is no longer an HTML sink. Removes large clusters of TaintedHtml and TaintedTextWithQuotes from console code (for example, echoed exception messages). Values stay tainted for SQL, shell and every other sink (#2068)
Manager::driver() union above lets taint follow into the driver's methods, so SSRF sinks behind a manager now report (#1743)
json_encode() with literal JSON_HEX_* flags, and Blade @json, as an HTML and quote escape (#1731, #1973)
e(), decrypt(), encrypt() and the Collection lookup defaults (first(), get(), last(), pull(), value()), so one tainted call no longer taints every other call of the same helper (#1907, #2112)
TaintedHeader for mail display names and subjects; address arguments stay sinks (#1960)
parent::dispatch() (#1967)
TaintedSql sinks for raw(), insertUsing(), insertOrIgnoreUsing() and rawValue() called on relations (#1739)
Fixes
where{Column}(), as where() does. Column validation and the return type are unchanged. A null-related finding (PossiblyNullArgument) may move from the where call to the next strict use of the variable (#2113)
Profile::whereUsername($user->username)->exists();
-// PossiblyNullArgumentwhere{Column}() calls from migration columns on models without @property docblocks, which removes most UndefinedMagicMethod and the Mixed* cascades on relation chains (on pixelfed, UndefinedMagicMethod 686 → 21) (#1934)
Builder passthru methods (exists(), toSql(), insert(), raw()) on relations instead of mixed (#1739)
Model::with() (#1689)
@method tags (#1745)
Request::user() on subclasses that override it (#1744)
UnknownModelAttribute on builder and relation creation calls (create(), createMany(), forceCreateQuietly()) (#1953)
:columns suffixes that Laravel does not parse in UndefinedModelRelation (whereHas('posts:id', ...)) (#1949, #1984)
MailMessage::text() and array MailMessage::view() in MissingView (#1692)
new XFactory() and Model::factory() sound (#1968)
foreignUuidFor()/foreignUlidFor() columns (#1691)
MorphTo docblock types to Psalm, fixing false UndefinedClass on intersection related types (#1941)
Collection, Eloquent\Collection and paginator return types to the runtime: random($n), max()/min(), renumbered keys, flatMap() keys, sum(), pad()/zip(), flip(), findOrFail([...]), modelKeys() (#1898, #1915, #1922)
prepend()/unshift()/add()/splice()/getOrPut() writes, and resolve closure defaults of getOrPut() and Cookie::queued() to the closure result (#1891, #2114)
filter()/reject() results from a class-typed callback parameter (#1913, #1916)
$default, named callbacks and same-class generics in when()/unless() return types (#1983)
keyBy() keys for int-backed enum columns and resolve mixed groupBy()/keyBy() keys to array-key (#1906, #2006)
Collections and make paginator stubs covariant in TValue (#1883, #1904)
Cache::flexible(), Cache::sear(), Cookie::queued(), Context::scope() and Schema introspection through their facades (#1741, #1956)
old() read as mixed (#2029)
class_basename() (no more ArgumentTypeCoercion), and stop MissingTemplateParam on Factory::resolveFactoryName() at errorLevel 1 (#2155)
config('app.aliases') and AliasLoader aliases with class_alias() instead of an extends stub, so an alias is the target class: enum cases (Alias::Case), \Eloquent as a Model, and attribute aliases resolve. Classes that only the old stubs extended may now report ClassMustBeFinal (#2133)
UndefinedMagicMethod on package facades whose root forwards calls through __call (World::countries()). Declared root methods keep their exact types; facades with their own @method tags stay sealed (#2138, #2139)
Tools\Request again ($request->get('x') reports UndefinedMagicMethod); the 1.x stubs had dropped its method sealing (#2119)
Full Changelog: v4.17.4...v4.18.0




