psalm/plugin-laravel
Laravel static analysis with built-in taint-based security scanning: traces user input from request to sink to catch SQL injection, XSS, shell injection, file traversal, SSRF, open redirects and timing-unsafe secret comparisons. Runs locally and in CI, no account or cloud upload.
Minimal steps
Upgrade the plugin:
composer require --dev psalm/plugin-laravel:^3.17
Upgrade to at least v3.17.0. If you previously saw Laravel vendor methods silently typed as mixed, v3.16.6 fixed that; v3.17.0 builds on it with sharper relation and callback inference plus two new opt-in rules.
Enable the Psalm plugin (or add it manually to psalm.xml):
./vendor/bin/psalm-plugin enable psalm/plugin-laravel
<plugins>
<pluginClass class="Psalm\LaravelPlugin\Plugin"/>
</plugins>
Optionally turn on the new opt-in checks (recommended for new projects or as a tightening step):
<findUnregisteredRouteNames value="true" />
<findUnconfiguredFilesystemDisks value="true" />
Or enable everything experimental at once with <experimental value="true" />. Prefer the specific flags in mature codebases so you don't inherit unrelated experimental rules.
The route rule inspects route(), to_route(), URL::route()/signedRoute()/temporarySignedRoute(), Redirect::route(), redirect()->route(), and url()->route(). Both rules need the booted app's route table / filesystems.disks config.
Run analysis with taint checking:
./vendor/bin/psalm --taint-analysis
Baseline legacy issues if needed:
./vendor/bin/psalm --set-baseline=psalm-baseline.xml
Where to look first
UnregisteredRouteName, UnconfiguredFilesystemDisk.TaintedSql, TaintedXss, TaintedShell.MixedAssignment, MixedMethodCall, MissingReturnType, UndefinedClass, InvalidArgument (now much more common inside when()/unless() callbacks).v3.17.0 wins to spot-check: relation generics from traits/delegating methods, {relation}_count / {relation}_exists nullability, withMin/withMax/withSum/withAvg attribute types, Collection::filter() narrowing, new $class() on class-string<Model>, and sync()/syncWithoutDetaching() accepting any Collection.First use case Verify the new opt-in rules on a branch before merging into your main pipeline:
return redirect()->route('users.shwo', $user);
// UnregisteredRouteName: Route name 'users.shwo' is not registered
Storage::disk('publik')->put($path, $contents);
// UnconfiguredFilesystemDisk: Disk 'publik' is not configured in filesystems.disks, did you mean 'public'?
These are cheap, high-signal typos. Run them once with the flags enabled, fix the findings, then decide whether to keep the flags on permanently.
Daily workflow
./vendor/bin/psalm --taint-analysis --show-info=false
- run: composer install --prefer-dist
- run: ./vendor/bin/psalm --output-format=github --threads=4
./vendor/bin/psalm --clear-cache
Incremental adoption
findUnregisteredRouteNames, findUnconfiguredFilesystemDisks) one at a time. They are cheap to satisfy and catch real bugs, so they are good candidates for "fix and keep on" rather than long-term baselines.MixedAssignment / MixedMethodCall around Laravel vendor APIs fixed in v3.16.6; otherwise you may hide regressions.v3.17.0 — trait-declared relations and delegating relation methods now resolve generic parameters.Use the improved v3.16.6 types
$hasListeners = app('events')->hasListeners('order.shipped'); // bool
$commands = Artisan::all(); // array<array-key, Command>
$columns = Schema::getColumnListing('users'); // list<string>
$html = (new MailMessage)->render(); // HtmlString|string
$post->tags()->saveMany($tagCollection); // accepts Eloquent Collection and preserves return type
New in v3.17.0: relations, counts, and aggregates
// Trait-declared relation: MorphMany<PaymentSlip, Member>, not MorphMany<Model, Model>
$member->paymentSlips();
// Counts are nullable until proven loaded
$post->comments_count ?? $post->loadCount('comments')->comments_count;
// Aggregate attributes typed from the related column
$post->comments_sum_votes; // int|null (or the related column's type)
// Relation calls inside non-final models keep $this as the declaring model
return $this->rel()->where('active', true);
Condition-aware when() / unless() callbacks
/** @var ?int $limit */
$query->when($limit, function (Builder $q, int $limit) { /* ... */ });
// InvalidArgument if you annotate the callback param as string — the value type is now checked
// Closure conditions pass their return type
$query->when(
fn (): bool => $request->boolean('featured'),
fn (Builder $q, bool $featured) => $q->where('featured', $featured),
);
Collection::filter() narrowing
$items->filter(fn (Countable $x): bool => $x instanceof Stringable);
// Collection<int, Countable&Stringable>
$classes->filter(fn (string $c): bool => is_subclass_of($c, Job::class));
// Collection<int, class-string<Job>>
Narrowing applies to where(callable) as well, and recognizes is_subclass_of(), is_a(), is_scalar(), is_iterable(), is_resource(), plus null checks (!is_null($x), $x !== null).
Model instantiation and sync()
/** @param class-string<Model> $class */
function make(string $class): Model { return new $class(); } // no UnsafeInstantiation
$user->roles()->sync($roles->pluck('id')); // any Collection accepted
Custom builders, scopes, and SoftDeletes
Scopes and SoftDeletes methods on a generic custom builder are now resolved per receiver model, not whichever model was scanned last:
/** @extends Builder<Member> */
class MemberBuilder extends Builder {}
// Parent and child sharing the same generic builder now get the receiving model's
// scope signatures and soft-delete methods instead of a stale, model-agnostic resolution.
Event dispatch nuance
Halting event dispatch now correctly returns mixed, because it returns the first non-null listener response:
$response = Event::until('order.shipping');
// mixed: narrow/validate the response at the call site
Facade and Macroable classes
v3.16.6+.\Storage::disk() now narrows to FilesystemAdapter, matching the facade — prefer the alias form where you can.Eloquent and relations
BelongsToMany::saveMany() / saveManyQuietly() accept an Eloquent Collection of related models and keep the return type.sync(), syncWithoutDetaching(), syncWithPivotValues() and the Laravel 13 *OrFail() variants accept any Collection./** @property string $name */
class User extends Model {}
CI tips
composer.lock, psalm.xml, psalm-baseline.xml, and the plugin version.vendor/** and generated directories from project files, but do not disable plugin stubs.Upgrade can surface new issues
v3.16.6 replaced silent mixed results with real types. v3.17.0 goes further: when()/unless() callback parameter types are now enforced, count attributes are int|null until proven loaded, and models overriding __construct with an incompatible signature are reported. Expect some churn, especially InvalidArgument inside condition callbacks. Run once without cache after upgrading and decide whether to fix issues or temporarily baseline them.
Count/aggregate nullability is intentional
{relation}_count and {relation}_exists are now nullable unless withCount() / withExists() / loadCount() / loadExists() is proven on that model, or the relation appears in $withCount. If you see int|null where you expected int, load the count explicitly rather than suppressing:
$post->loadCount('comments');
$post->comments_count; // int
Model constructor overrides
Model is now @psalm-consistent-constructor. A model with an incompatible __construct signature will be flagged — and it's a genuine bug, since Eloquent's own new static($attributes) already breaks on it. Fix the signature rather than suppressing.
Still seeing mixed on Laravel classes
Check:
psalm/plugin-laravel is at least v3.17.0.psalm.xml.--no-plugins.Affected classes in older versions included Events\Dispatcher, Http\Client\PendingRequest, Http\Client\Response, FormRequest, Pipeline, and several facades.
Request::input() no longer @psalm-mutation-free
It lazily decodes JSON. If a FormRequest overriding input() was previously flagged, that false positive is gone. Don't re-add purity annotations to your overrides.
Opt-in rules: read the enablement model
<experimental value="true" /> turns on both new rules plus anything else experimental. In a long-lived codebase, prefer the targeted <findUnregisteredRouteNames/> and <findUnconfiguredFilesystemDisks/> flags — that way future experimental rules don't silently land in your build. These rules need a booted app's route/disk config, so they behave differently in minimal CI containers: ensure your config is loaded and filesystems.php is present. Route names registered dynamically (package service providers, route caching in a container without an app key/DB) may be missing — verify before treating a hit as a real typo.
Do not over-suppress
Avoid adding @psalm-suppress MixedAssignment or @psalm-suppress MixedMethodCall around APIs fixed in v3.16.6. Suppress only after confirming the issue is a false positive or an intentional dynamic boundary.
Halting events are intentionally mixed
Event::until() and dispatch(..., true) return the first non-null listener response. Narrow the result explicitly:
$result = Event::until('order.shipping');
if (is_string($result)) {
// handle string result
}
Taint false positives Validate input and use allowlists rather than suppressing taint errors globally:
$allowed = ['name', 'created_at'];
$column = in_array($request->input('sort'), $allowed, true)
? $request->input('sort')
: 'name';
Schema/model inference
If Eloquent columns are not recognized, add @property annotations or ensure your model metadata generation/schema dump workflow is current. Do not rely on stale generated metadata after migrations change.
Custom builder scopes changed owner
Scopes and SoftDeletes methods on a generic custom builder shared by a parent and child model now resolve against the receiver model instead of the last-scanned one. If you had suppressions around phantom "undefined method" or wrong-parameter errors on such a builder, retest them — results may have flipped in either direction.
First-class callables with zend.assertions=1
v3.17.0 fixes a crash on first-class callables ($request->input(...), Artisan::command(...), Job::dispatch(...)) when assertions are enabled — which is the default in many dev images. If you pinned an older plugin to dodge that crash, you can unpin. Also note v3.17.0 stops analysis-time type checks from autoloading analyzed classes, so a vendor dependency raising a load-time deprecation no longer kills the run or silently disables the plugin.
CI cache invalidation
If CI results differ from local results, clear Psalm cache and verify the cache key includes the plugin version and composer.lock. File timestamp restoration can also help.
Performance
--threads appropriate for the runner.Debugging
@psalm-trace:
/** @psalm-trace $commands */
$commands = Artisan::all();
/** @psalm-trace $member->paymentSlips() */
--no-cache when validating an upgrade.--show-info=true to see non-error diagnostics.v3.17.0 fixes that — but keep deprecation noise in vendor deps on your radar.Extension points
psalm.xml under version control so CI and local analysis use the same Laravel stubs, type extensions, and opt-in rule flags.How can I help you explore Laravel packages today?