Weave Code
Weaver
Helps Laravel developers discover, compare, and choose open-source packages. See popularity, security, maintainers, and scores at a glance to make better decisions.
Feedback
Share your thoughts, report bugs, or suggest improvements.
Subject
Message

Plugin Laravel Laravel Package

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.

View on GitHub
Deep Wiki
Context7

Getting Started

Minimal steps

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

  2. 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>
    
  3. 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.

  4. Run analysis with taint checking:

    ./vendor/bin/psalm --taint-analysis
    
  5. Baseline legacy issues if needed:

    ./vendor/bin/psalm --set-baseline=psalm-baseline.xml
    

Where to look first

  • New typo-catchers: UnregisteredRouteName, UnconfiguredFilesystemDisk.
  • Security issues: TaintedSql, TaintedXss, TaintedShell.
  • Laravel-specific type issues: 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.


Implementation Patterns

Daily workflow

  • Run Psalm locally before pushing:
    ./vendor/bin/psalm --taint-analysis --show-info=false
    
  • In CI, use GitHub output and caching:
    - run: composer install --prefer-dist
    - run: ./vendor/bin/psalm --output-format=github --threads=4
    
  • After upgrading the plugin, clear the Psalm cache once:
    ./vendor/bin/psalm --clear-cache
    

Incremental adoption

  • Start at a permissive level and tighten over time.
  • Baseline existing errors, then fix new issues on changed files.
  • Enable the new opt-in flags (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.
  • Remove old suppressions for MixedAssignment / MixedMethodCall around Laravel vendor APIs fixed in v3.16.6; otherwise you may hide regressions.
  • Revisit relation return-type suppressions from before 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

  • Facades and Macroable-backed classes are better typed in v3.16.6+.
  • \Storage::disk() now narrows to FilesystemAdapter, matching the facade — prefer the alias form where you can.
  • Prefer real inferred types over blanket suppressions.
  • For custom macros, keep local docblocks/annotations where Psalm cannot infer the macro signature.

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.
  • Keep model property annotations current when schema inference is not available:
    /** @property string $name */
    class User extends Model {}
    

CI tips

  • Cache key should include composer.lock, psalm.xml, psalm-baseline.xml, and the plugin version.
  • Restore file timestamps if your CI cache behaves inconsistently.
  • Exclude vendor/** and generated directories from project files, but do not disable plugin stubs.

Gotchas and Tips

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:

  1. psalm/plugin-laravel is at least v3.17.0.
  2. The plugin is enabled in psalm.xml.
  3. Psalm cache was cleared after the upgrade.
  4. You are not running with --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

  • Use --threads appropriate for the runner.
  • Exclude tests/generated files only when appropriate.
  • Prefer targeted baselines over disabling entire issue types.
  • The new route/disk rules add a bootstep; if you run Psalm in many shards, enable them in one dedicated job rather than every shard.

Debugging

  • Inspect inferred types with @psalm-trace:
    /** @psalm-trace $commands */
    $commands = Artisan::all();
    
  • Trace relation generics where you suspect a stale inference:
    /** @psalm-trace $member->paymentSlips() */
    
  • Run with --no-cache when validating an upgrade.
  • Use --show-info=true to see non-error diagnostics.
  • If a vendor file with a load-time deprecation previously killed the run or silently disabled the plugin, v3.17.0 fixes that — but keep deprecation noise in vendor deps on your radar.

Extension points

  • Add project-specific Psalm plugins for custom checks or taint sources/sinks.
  • Prefer narrow suppressions with explanations over broad config-level ignores.
  • Keep plugin configuration in psalm.xml under version control so CI and local analysis use the same Laravel stubs, type extensions, and opt-in rule flags.
Weaver

How can I help you explore Laravel packages today?

Conversation history is not saved when not logged in.
Prompt
Add packages to context
No packages found.
andydefer/laravel-notification
adachsoft/cli-process-tester
jhaskell13/laraflow
sebastienheyd/boilerplate-media-manager
ccast/tagixo-primix
pretendtrue/laravel-builder
sawmainek/laravel-scout-tntsearch-driver
tomshaw/laravel-calendar-table
dibakar/laravel-ownership
steadrun/laravel-monitor
labyrinthe/connect-laravel
elmogy/layan
drewdan/sentinel-client
dskripchenko/laravel-llm
fatryst/nuonuo
stevenay/myanfont
gmostafa/php-graphql-client
xeviant/paystack
snowsoft/faker
meditationsteps/blogetc