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.
filesystems.disks config. That is an architectural shift worth naming explicitly: analysis now depends on a bootable application and its configuration under CI, not only on source code.$member->paymentSlips() → MorphMany<PaymentSlip, Member>), {relation}_count/{relation}_exists are correctly nullable until a load is proven, withMin/Max/Sum/Avg are typed from the related column, and Model is marked @psalm-consistent-constructor. This closes a long-standing class of false positives on fluent relation code.Events\Dispatcher, Http\Client\PendingRequest, FormRequest, Pipeline, facades) stands and must be verified as still effective, not assumed. Treat that verification as a regression check on this upgrade.UnconfiguredFilesystemDisk even ships a closest-name suggestion. This is the cheapest class of bug to catch and the least controversial to gate on, which makes these rules easier to adopt than a strictness bump.composer require plus psalm-laravel init.<findUnregisteredRouteNames value="true" /><findUnconfiguredFilesystemDisks value="true" /><experimental value="true" /> (umbrella; enables both, and any future experimental rule)
Prefer the explicit flags. The umbrella flag is a forward-compatibility liability — it silently opts the project into rules shipped in later patch releases.UnregisteredRouteName requires the booted app's route table; UnconfiguredFilesystemDisk requires filesystems.disks to be loaded. In CI this means the analysis step must have enough environment/config to boot the app and resolve config — route caching, APP_KEY, DB connections during boot, and env-specific config all become analysis-time concerns.when()/unless() callback parameters are now type-checked, Collection::filter(callable) narrows like where(), {relation}_count becomes nullable, and Model's consistent-constructor marker is applied. Expect a mixed delta — new errors in some places, disappearing errors in others.config(['filesystems.disks.x' => ...])).route:cache) or a boot path that skips service providers in the analysis environment.
This is the classic static-analysis failure mode: the tool is correct about the configuration it saw, and the configuration it saw is not the one that ships.when($limit, function (Builder $q, string $limit) {...}) → InvalidArgument where the callback parameter was previously unchecked. Common in real codebases; expect a cluster here.{relation}_count now nullable → PossiblyNullOperand / null-safety findings on code that assumed int without a proven loadCount().Model consistent-constructor → models overriding __construct with an incompatible signature are now reported.Collection::filter(callable) narrowing → downstream MoreSpecificReturnType / LessSpecificReturnStatement in typed helper signatures.UnsafeInstantiation on new $class() for class-string<Model>, InvalidArgument/ImplicitToStringCast on sync($collection->pluck(...)), RedundantCondition on {relation}_count, and MoreSpecificReturnType on return $this->rel()->where(...) in non-final models all become unnecessary. Un-pruned baseline entries are now silent dead weight masking future regressions.$request->input(...), Artisan::command(...), Job::dispatch(...)) no longer crash with zend.assertions=1. The silent-disable bug is the significant one: any project that saw mysteriously empty results on a prior run may have been running with the plugin effectively off. Re-verify prior "clean" runs on those projects.^4.8 referenced vs. 3.x tags). Verify before bumping; this release does not change that.UnregisteredRouteName will generate false positives that erode trust in the whole run.route:cache present in the pipeline before analysis, and does that change the resolved route table?<experimental>
UnregisteredRouteName — dozens to hundreds of named routes, heavy redirect()->route() and to_route() usage, frequent renames. This is where typos actually reach production.UnconfiguredFilesystemDisk; the closest-name suggestion turns a silent runtime exception into a lint error.3.x tags before bumping (carried forward, still open)../vendor/bin/psalm --no-cache --output-format=github
This isolates the changes that are not opt-in — the when()/unless() checks, nullable relation counts, consistent-constructor, filter() narrowing.UnsafeInstantiation, sync() argument errors, RedundantCondition on _count, non-final-model relation return types). This is the inverse of the v3.16.6 step and should be done deliberately, not by regeneration.Http:: call or FormRequest flow). This catches a silent regression or a re-run of the plugin-disabled bug.findUnconfiguredFilesystemDisk first, on a branch, with the app bootable and config loaded. Lower false-positive risk than routes; validates that the boot path works.findUnregisteredRouteNames second, after confirming route-table completeness (no tenancy, no env-conditional route files, or an analysis config that enumerates them).psalm.xml: Additive only. Two new optional flags; existing configuration, plugin registration, and taint source/sink config are untouched. No breaking change.\Storage::disk() → narrows to FilesystemAdapter (now matches the facade)MorphMany<PaymentSlip, Member>Collection::filter(callable) → narrowed type, e.g. Collection<int, Countable&Stringable>, Collection<int, class-string<Job>>{relation}_count / {relation}_exists → int|null / bool|null unless a load is provenwithMin/Max/Sum/Avg attributes → typed from the related columnwhen() callbacks → truthy value type; unless() → falsy value type; Closure condition → its return typeUnsafeInstantiation on new $class() for models, InvalidArgument/ImplicitToStringCast on BelongsToMany::sync() family, RedundantCondition on {relation}_count, MoreSpecificReturnType/LessSpecificReturnStatement on non-final relation delegation, and any suppression around Request::input() on a FormRequest (the @psalm-mutation-free marker was dropped).Storage::disk().--level 3 only after both deltas are absorbed.psalm.xml, prefer named flags over <experimental>, and add a comment explaining why each is on or off.4.x, not an imminent v3.x break.Dispatcher, PendingRequest, Response, FormRequest, Pipeline, and facades — plus the new route/disk surfaces once enabled.sync(), UnsafeInstantiation, and count nullability; confirm it was fixed, not newly hidden.--no-cache and psalm.log remain first-line. For the new rules specifically, the diagnostic is what did the analysis-time app boot see — print the resolved route table / disk list in CI when chasing a disputed finding.when/unless), #1646 (trait relations), #1628 (counts), #1661 (autoload crash).APP_KEY, possibly DB) that it may not have had.tests/, --threads, GitHub Actions caching, igbinary, and rm -rf ~/.cache/psalm for memory on large codebases (>50K LOC).| Failure Scenario | Impact | Mitigation |
|---|---|---|
| Route rule enabled against an analysis config missing package/tenant routes | Flood of false positives; team disables the rule permanently and loses a genuinely useful check | Validate route-table completeness first; enable on a branch; suppress narrowly per dynamic route family with a documented reason |
| Disk rule enabled where disks come from env vars | Same — noise, then abandonment | Enumerate disks for analysis, or scope the rule; don't blanket-disable |
<experimental value="true" /> used as shorthand |
Silently opts into rules shipped in later patch releases | Use the two explicit flags; treat <experimental> as a governance decision, not convenience |
| Baseline regenerated instead of pruned | Obsolete entries mask future regressions; the "clean" state is fiction | Prune the specific fixed shapes explicitly; require a rationale in the baseline commit |
when()/unless() callback typing produces an unexpected cluster |
CI red on unchanged code | Isolate the inference-only delta (step 2) before enabling any new rule; batch the fix |
| Prior run was silently plugin-disabled (autoload deprecation) | Historical baselines for that run are invalid; blind spots assumed closed | Canary-check taint reporting and a known-bad sample after upgrade |
{relation}_count nullability breaks downstream arithmetic |
New null-safety findings | Add explicit loadCount() where the count is actually required, or guard; this is usually a real latent bug |
Wrong composer constraint (^4.8 vs 3.x) |
Unreproducible resolution | Verify against published tags before bumping |
| App boot adds CI time or fails on missing env | Slow or failing pipeline | Measure the delta; provide a minimal analysis-specific env |
| Residual taint edge cases | Security gaps remain | Combine with runtime scanning (e.g., Snyk) |
redirect()->route('users.shwo', $user) and Storage::disk('publik'). These are the most intuitive findings the tool produces and the fastest way to build trust in it as a preventive tool rather than a compliance tax.--set-baseline as a first response, and now also forbid blanket baseline regeneration.sync($collection), new $class() on models, and {relation}_count no longer report — so nobody "fixes" a codebase that was already correct or removes a suppression that is still load-bearing elsewhere.when() callback type checking, Collection::filter() narrowing (Countable&Stringable, class-string<Job>), trait-declared relation generics, Storage::disk() narrowing to FilesystemAdapter.whereDate()/DateTimeInterface resolution and the v3.16.6 mixed/taint fix should both remain in force; noise returning in either area indicates a regression worth reporting upstream.How can I help you explore Laravel packages today?