psalm/plugin-laravel
Laravel Psalm plugin for deep static analysis plus taint-based security scanning. Detects SQL injection, XSS, SSRF, shell injection, path traversal, and open redirects by tracking user input through Laravel code without running it.
| Dependency | v3 | v4 |
|---|---|---|
| PHP | ^8.2 | ^8.2 |
| Laravel | 11, 12 | 12, 13 |
| Psalm | 6, 7 (beta) | 7 only |
Laravel 11 and Psalm 6 are no longer supported. If you need them, stay on v3.
v4 requires vimeo/psalm ^7.0.0-beta17 or later. If your project still uses Psalm 6, upgrade Psalm first:
composer require --dev vimeo/psalm:^7.0.0-beta17
Psalm 7 is still in beta. You may need to add this to your project's composer.json:
{
"minimum-stability": "dev",
"prefer-stable": true
}
Psalm 7 introduces new issue types that may surface in your codebase:
MissingPureAnnotation -- a method has no side effects but lacks [@psalm-pure](https://github.com/psalm-pure).MissingAbstractPureAnnotation -- an abstract method should be declared [@psalm-pure](https://github.com/psalm-pure) so all implementations are guaranteed pure.MissingInterfaceImmutableAnnotation -- an interface should be [@psalm-immutable](https://github.com/psalm-immutable) so all implementations are guaranteed immutable.Additionally, MissingImmutableAnnotation (introduced in Psalm v3) fires when a class has no mutable state but lacks [@psalm-immutable](https://github.com/psalm-immutable).
Why these annotations matter beyond documentation:
[@psalm-pure](https://github.com/psalm-pure) enables taint specialization — Psalm tracks whether a pure function's return value is tainted based on whether its arguments are tainted. Without it, taint can be lost or incorrectly propagated through the function.[@psalm-immutable](https://github.com/psalm-immutable) enables per-instance property taint tracking — without it, a tainted property on one instance can pollute type inference across all instances of the class.If you use taint analysis (security scanning), fixing these is recommended. Otherwise, suppress them during the upgrade and address later:
<issueHandlers>
<MissingAbstractPureAnnotation errorLevel="suppress" />
<MissingImmutableAnnotation errorLevel="suppress" />
<MissingInterfaceImmutableAnnotation errorLevel="suppress" />
<MissingPureAnnotation errorLevel="suppress" />
</issueHandlers>
All Eloquent relation stubs gained additional template parameters. If your codebase has [@psalm-return](https://github.com/psalm-return) (or [@return](https://github.com/return)) annotations with relation generics, they must be updated:
| Relation type | v3 signature | v4 signature |
|---|---|---|
BelongsTo |
BelongsTo<TRelated> |
BelongsTo<TRelatedModel, TDeclaringModel> |
HasOne |
HasOne<TRelated> |
HasOne<TRelatedModel, TDeclaringModel> |
HasMany |
HasMany<TRelated> |
HasMany<TRelatedModel, TDeclaringModel> |
BelongsToMany |
BelongsToMany<TRelated> or BelongsToMany<TRelated, TDeclaringModel> |
BelongsToMany<TRelatedModel, TDeclaringModel, TPivotModel, TAccessor> (upd. v4.7) |
MorphOne |
MorphOne<TRelated> |
MorphOne<TRelatedModel, TDeclaringModel> |
MorphMany |
MorphMany<TRelated> |
MorphMany<TRelatedModel, TDeclaringModel> |
MorphTo |
MorphTo<TRelated> |
MorphTo<TRelatedModel, TDeclaringModel> |
MorphToMany |
MorphToMany<TRelated> or MorphToMany<TRelated, TDeclaringModel> |
MorphToMany<TRelatedModel, TDeclaringModel, TPivotModel, TAccessor> (upd. v4.7) |
HasOneThrough |
HasOneThrough<TRelated> |
HasOneThrough<TRelatedModel, TIntermediateModel, TDeclaringModel> |
HasManyThrough |
HasManyThrough<TRelated> |
HasManyThrough<TRelatedModel, TIntermediateModel, TDeclaringModel> |
Collection, EloquentCollection, and Builder are unchanged.
In Psalm 6 you had to pass --taint-analysis as a separate flag.
Psalm 7 combines type analysis and taint analysis into a single run by default.
No flags needed — just run ./vendor/bin/psalm.
Plugin issues (suppressible via <PluginIssue>):
InvalidConsoleArgumentName -- argument() references an undefined name in the command's $signatureInvalidConsoleOptionName -- option() references an undefined name in the command's $signatureNoEnvOutsideConfig -- env() called outside the application's config_path() (env() returns null when the config is cached). Configurable via <configDirectory> for non-standard layouts.<issueHandlers>
<PluginIssue name="InvalidConsoleArgumentName" errorLevel="suppress" />
<PluginIssue name="InvalidConsoleOptionName" errorLevel="suppress" />
<PluginIssue name="NoEnvOutsideConfig" errorLevel="suppress" />
</issueHandlers>
Psalm built-in issues (new detections via taint analysis):
TaintedSql -- where(), orWhere(), and other query builder methods now have [@psalm-taint-sink](https://github.com/psalm-taint-sink) sql annotations, catching SQL injection via dynamic column names#[Scope] attribute support -- Laravel 12+ scope detection alongside the traditional scope method prefixcasts() method without executing it)pseudo_property_set_types) for model propertiesAttribute<TGet, TSet> accessor templatesafter() closures, Blueprint::rename(), addColumn(), and more migration methods supportedloadMigrationsFrom()# 1. Update PHP to 8.2+ and Laravel to 12+ if needed
# 2. Upgrade Psalm to v7
composer require --dev vimeo/psalm:^7.0.0-beta17
# 3. Upgrade the plugin
composer require --dev psalm/plugin-laravel:^4.0
# 4. Update relation generic annotations (add declaring model parameter)
#
# Option A — Psalter plugin (handles [@return](https://github.com/return) and [@psalm-return](https://github.com/psalm-return), AST-aware):
./vendor/bin/psalter --plugin=/vendor/psalm/plugin-laravel/tools/psalter/UpgradeRelationAnnotations.php --dry-run
./vendor/bin/psalter --plugin=/vendor/psalm/plugin-laravel/tools/psalter/UpgradeRelationAnnotations.php
#
# Option B — AI prompt (paste into Claude Code / Cursor / Copilot):
#
# Update all Eloquent relation [@return](https://github.com/return) / [@psalm-return](https://github.com/psalm-return) annotations in app/ to
# match the psalm-plugin-laravel v4 signatures. Use grep to find affected files,
# then edit each one with sed or direct file edits.
#
# Rules (apply to both [@return](https://github.com/return) and [@psalm-return](https://github.com/psalm-return) lines):
# BelongsTo<T> → BelongsTo<T, self>
# HasOne<T> → HasOne<T, self>
# HasMany<T> → HasMany<T, self>
# MorphOne<T> → MorphOne<T, self>
# MorphMany<T> → MorphMany<T, self>
# MorphTo<T> → MorphTo<T, self>
# BelongsToMany<T> → BelongsToMany<T, self, \Illuminate\Database\Eloquent\Relations\Pivot, 'pivot'>
# BelongsToMany<T, self> → BelongsToMany<T, self, \Illuminate\Database\Eloquent\Relations\Pivot, 'pivot'>
# BelongsToMany<T, $this> → BelongsToMany<T, $this, \Illuminate\Database\Eloquent\Relations\Pivot, 'pivot'>
# MorphToMany<T> → MorphToMany<T, self, \Illuminate\Database\Eloquent\Relations\MorphPivot, 'pivot'>
# MorphToMany<T, self> → MorphToMany<T, self, \Illuminate\Database\Eloquent\Relations\MorphPivot, 'pivot'>
# MorphToMany<T, $this> → MorphToMany<T, $this, \Illuminate\Database\Eloquent\Relations\MorphPivot, 'pivot'>
# HasManyThrough<T> → HasManyThrough<T, IntermediateModel, self> (read the method body to find IntermediateModel)
# HasOneThrough<T> → HasOneThrough<T, IntermediateModel, self> (read the method body to find IntermediateModel)
#
# Do not touch annotations that already have the correct number of type params.
# Do not touch [@param](https://github.com/param) or [@var](https://github.com/var) annotations.
# 5. Run Psalm and update your baseline
./vendor/bin/psalm --set-baseline=psalm-baseline.xml
# 6. Review new issues
# - InvalidConsoleArgumentName / InvalidConsoleOptionName are real bugs — fix them
# - NoEnvOutsideConfig — move env() calls into config files
# - TaintedSql on Builder::where() — review for actual SQL injection risk
How can I help you explore Laravel packages today?