packagist psalm/plugin-laravel v4.18.0

2 hours ago

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 --blade

Every 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.

A Blade template with a misspelled enum case and property, and the two Psalm errors pointing at template lines 9 and 10

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.

🌕 All changes

Requirements

  • Require Laravel 12.27+ or 13.20+ (was 12.14+ or 13.3+), enforced as a conflict on laravel/framework (#1922, #1995, #2144, #2145)
  • Require Psalm 7.0.0-rc16+, as 4.17.3 does (#2140, #2141, #2142)
  • Stop requiring the 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

Checked without any @var:

  • Broken references after a refactor: classes, constants, enum cases, static calls
  • Calls to typed helpers and facades (route(), auth(), Str::), class components, @props defaults
  • Plugin rules inside templates when enabled: UnregisteredRouteName, MissingView on @include, MissingTranslation
  • 🛡️ Taint inside the template ({!! 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)

With a @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.

View data checks are experimental: 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).

Apps using livewire/blaze: templates are compiled with Blaze switched off, so <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

  • Add 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)
  • Root Laravel convention entry points in Psalm 7 dead-code detection: __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)
  • Narrow app(), make() and $app[...] over unions of string literals (#1802)
     app($flag ? CacheManager::class : DatabaseManager::class);
    -// mixed
    +// CacheManager|DatabaseManager
  • Infer 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)
  • Type withPivot() and withTimestamps() columns in BelongsToMany pivot shapes, using() custom pivots included (#1951, #1992, #2008, #2094)
  • Type the closure parameter of 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 VehicleBuilder
  • Type closure parameters of Collection::tap(), pipe(), chunkWhile() and the whenEmpty() family (#1901, #2100)

🛡️ Taint analysis

  • Treat artisan commands, migrations and seeders as command-line-only code: their 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)
  • Reach integration drivers' HTTP calls: the Manager::driver() union above lets taint follow into the driver's methods, so SSRF sinks behind a manager now report (#1743)
  • Support laravel/ai 1.x, which enables the LLM prompt sinks and response sources on 1.x (0.x is no longer covered) (#1588, #1765)
  • Treat json_encode() with literal JSON_HEX_* flags, and Blade @json, as an HTML and quote escape (#1731, #1973)
  • Specialize taint per call site for 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)
  • Stop reporting TaintedHeader for mail display names and subjects; address arguments stay sinks (#1960)
  • Report taint reaching an inherited job constructor sink through parent::dispatch() (#1967)
  • Add TaintedSql sinks for raw(), insertUsing(), insertOrIgnoreUsing() and rawValue() called on relations (#1739)

Fixes

Eloquent:

  • Accept any value in dynamic 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();
    -// PossiblyNullArgument
  • Resolve dynamic where{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)
  • Resolve Eloquent Builder passthru methods (exists(), toSql(), insert(), raw()) on relations instead of mixed (#1739)
  • Return the model's custom builder from Model::with() (#1689)
  • Resolve facade calls through the facade root's @method tags (#1745)
  • Narrow Request::user() on subclasses that override it (#1744)
  • Check UnknownModelAttribute on builder and relation creation calls (create(), createMany(), forceCreateQuietly()) (#1953)
  • Report :columns suffixes that Laravel does not parse in UndefinedModelRelation (whereHas('posts:id', ...)) (#1949, #1984)
  • Check MailMessage::text() and array MailMessage::view() in MissingView (#1692)
  • Keep count arguments of new XFactory() and Model::factory() sound (#1968)
  • Resolve class-constant column names in migrations and register foreignUuidFor()/foreignUlidFor() columns (#1691)
  • Defer MorphTo docblock types to Psalm, fixing false UndefinedClass on intersection related types (#1941)

Collections and support:

  • Match 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)
  • Widen prepend()/unshift()/add()/splice()/getOrPut() writes, and resolve closure defaults of getOrPut() and Cookie::queued() to the closure result (#1891, #2114)
  • Narrow filter()/reject() results from a class-typed callback parameter (#1913, #1916)
  • Include $default, named callbacks and same-class generics in when()/unless() return types (#1983)
  • Narrow keyBy() keys for int-backed enum columns and resolve mixed groupBy()/keyBy() keys to array-key (#1906, #2006)
  • Type higher-order proxy calls on unions of different collection classes (#1923, #2101)
  • Accept lookups on empty Collections and make paginator stubs covariant in TValue (#1883, #1904)
  • Type Cache::flexible(), Cache::sear(), Cookie::queued(), Context::scope() and Schema introspection through their facades (#1741, #1956)
  • Type a keyed old() read as mixed (#2029)
  • Accept a plain string in class_basename() (no more ArgumentTypeCoercion), and stop MissingTemplateParam on Factory::resolveFactoryName() at errorLevel 1 (#2155)

Facades and aliases:

  • Register 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)
  • Stop reporting 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)

Integrations:

  • Report undefined methods on laravel/ai 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

Don't miss a new plugin-laravel release

NewReleases is sending notifications on new releases.