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.
UnregisteredRouteName and UnconfiguredFilesystemDisk — that turn string-keyed lookups (route('users.shwo'), Storage::disk('publik')) from runtime-only failures into CI failures. This is a policy decision, not just a tooling upgrade: teams can now choose to make a class of "typo → 500 in production" bugs unmergeable, and can stage that decision independently of the base plugin upgrade.{relation}_count / {relation}_exists are now int|null unless withCount()/loadCount()/$withCount is proven on that model, and withMin/withMax/withSum/withAvg attributes take the related column's type. Previously these were typed as always-present ints, which produced RedundantCondition false positives that trained engineers to ignore Psalm output. Fewer spurious warnings directly increases the merge gate's credibility — a measurable adoption lever.return $this->allRevisions()->where(...)), and $this is preserved as the declaring model for relation calls in non-final parent/child models. Trait-composed relations and shared base models are the norm in mature Laravel apps — this release moves those from MorphMany<Model, Model> to MorphMany<PaymentSlip, Member>, which is the difference between a typed domain layer and an untyped one.when()/unless() callback parameters are typed from the condition value (truthy for when(), falsy for unless(), closure return type for Closure conditions). This is where conditional filter builders, admin search screens, and report generators live — previously a callback taking string where the condition produced int went completely unverified.Collection::filter(callable) now narrows like where(callable), and is_subclass_of(), is_a(), is_scalar(), is_iterable(), is_resource(), and null checks are recognized in both. Domain code that filters heterogeneous collections now yields Countable&Stringable or class-string<Job> instead of the widened input type — better autocomplete, better refactor safety, better analysis of downstream calls.BelongsToMany::sync() / syncWithoutDetaching() / syncWithPivotValues() and the Laravel 13 *OrFail() variants now accept any Collection, not just the exact generic shape. $user->roles()->sync($roles->pluck('id')) no longer trips InvalidArgument / ImplicitToStringCast. This was a common pain point in role/permission and tagging features where teams had been writing suppressions.Model is now @psalm-consistent-constructor, so new $class() on a class-string<Model> stops being UnsafeInstantiation — unblocking factory patterns, generic repository/registry code, and polymorphic instantiation helpers. The tradeoff is honest: models overriding __construct incompatibly are now reported, because Eloquent's own new static($attributes) already breaks on them.$request->input(...), Artisan::command(...), Job::dispatch(...)) no longer crash with zend.assertions=1. Combined with v3.16.6's silent-mixed fix, this continues the pattern of making a green build mean something.SoftDeletes resolution on shared custom builders, aggregate-attribute provenance tracking, and is_subclass_of-aware collection narrowing are all deep framework-and-analyzer integration work. An in-house stub set would need continuous re-derivation from Laravel internals to match this, and would never surface the silent-disable class of bug.<findUnregisteredRouteNames>, <findUnconfiguredFilesystemDisks>, or <experimental value="true" />), rollout can be planned in phases: upgrade plugin → triage existing-codebase fallout → enable rules on a new-code path or with a baseline → promote to global. That is a product rollout plan, not a single dependency bump.Adopt if:
Macroable-extended or facade-heavy code paths where methods previously resolved to mixed and were therefore unanalyzed.$this->rel()->where(...)), or shares a custom builder across parent and child models — this release specifically corrects inference for all three.withCount / withExists / withMin / withMax / withSum / withAvg and were seeing RedundantCondition noise on the generated attributes.when()/unless() with typed callbacks and want those parameter types verified.sync() / syncWithoutDetaching() / syncWithPivotValues() passing collections (role/permission, tagging, team-membership features).class-string<Model> via a factory, registry, or repository pattern.zend.assertions=1 (common in dev/CI) and use first-class callable syntax anywhere — prior versions could crash outright.Look elsewhere if:
psalm/[email protected]).Caution:
mixed or over-narrow types previously masked them. v3.16.6 already warned of this; expect a second, smaller wave here — particularly from the {relation}_count nullability change, which will suppress some RedundantCondition while possibly surfacing PossiblyNullOperand-style issues at call sites that assumed non-null.<experimental value="true" /> switches on both rules at once plus other experimental behavior — prefer the individual flags if you want controlled rollout.sync() now accepting any Collection removes a validation signal; if your team was relying on that error to catch passing the wrong collection (e.g. models instead of IDs), that protection is gone — cover it with a test instead.Model as @psalm-consistent-constructor will now flag models that override __construct incompatibly. The plugin is right that Eloquent already breaks there, but the issue will appear as new noise on upgrade."This release moves our static analysis from 'catches some bugs' to 'catches a specific, expensive class of bugs at merge time.' Two new opt-in checks flag misspelled route names and unconfigured storage disks — both of which currently fail only when a user hits that code path, often in production. Beyond that, the analyzer now understands how our Eloquent relations and collections actually work, so it produces far fewer false alarms and far more real findings on the code most of our business logic lives in. It also fixes two cases where the tool could silently stop working. No runtime impact, no new cost, no infrastructure change. Rollout is phased and reversible: upgrade, triage, then switch the new checks on."
Key Outcomes:
withCount/withSum attributes and bulk relation sync() calls, which directly increases how much engineers trust a red build.zend.assertions=1.composer update.*"v3.17.0 is mostly inference and reliability, plus our first two opt-in rules.
New opt-in checks (off by default):
UnregisteredRouteName — validates route names in route(), to_route(), URL::route()/signedRoute()/temporarySignedRoute(), Redirect::route(), redirect()->route(), and url()->route() against the booted app's route table. Enable with <findUnregisteredRouteNames value="true" /> (or <experimental value="true" />).UnconfiguredFilesystemDisk — validates Storage::disk() names against filesystems.disks, with a closest-name suggestion. Enable with <findUnconfiguredFilesystemDisks value="true" />. Bonus: \Storage::disk() now narrows to FilesystemAdapter like the facade already did. 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'?
Sharper inference:
when()/unless() callback params are typed from the condition: when() passes the truthy value, unless() the falsy one, a Closure condition passes its return type. Wrong-type callbacks are now InvalidArgument.return $this->allRevisions()->where(...)) — MorphMany<Model, Model> becomes MorphMany<PaymentSlip, Member>.Collection::filter(callable) narrows like where(callable), and both recognize is_subclass_of(), is_a(), is_scalar(), is_iterable(), is_resource(), !is_null($x), $x !== null — yielding Countable&Stringable, class-string<Job>, etc.BelongsToMany::sync() / syncWithoutDetaching() / syncWithPivotValues() and the Laravel 13 *OrFail() variants accept any Collection; $user->roles()->sync($roles->pluck('id')) no longer errors.$this stays the declaring model for relation calls in non-final parent/child models — no more MoreSpecificReturnType / LessSpecificReturnStatement on return $this->rel()->where(...).SoftDeletes methods resolve per receiver model on a custom builder shared between parent and child, instead of whichever model was scanned last.Fixes:
{relation}_count / {relation}_exists are int|null unless withCount()/withExists()/loadCount()/loadExists() is proven on that model (or in $withCount); withMin/withMax/withSum/withAvg typed from the related column. Expect some RedundantCondition warnings to disappear and a few null-safety issues to appear.Model is @psalm-consistent-constructor — new $class() on a class-string<Model> is no longer UnsafeInstantiation; a model overriding __construct incompatibly is now reported (Eloquent's own new static($attributes) already breaks on it).@psalm-mutation-free dropped from Request::input() (it lazily decodes JSON) — FormRequest overrides no longer flagged.$request->input(...), Artisan::command(...), Job::dispatch(...)) no longer crash with zend.assertions=1.Action:
composer update psalm/plugin-laravel, re-run analysis, triage the delta (don't suppress — the new findings are the point).<findUnregisteredRouteNames> first; it's the highest signal-to-noise check in this release. Baseline existing hits, then ratchet.<findUnconfiguredFilesystemDisks> next — usually a short list of renamed/legacy disks.sync()'s old strictness to catch passing the wrong collection, add a test; that check is intentionally gone."Why Now?
composer update, one triage pass, two optional config flags.zend.assertions fixes eliminate paths where analysis silently did nothing — the same class of problem v3.16.6 addressed for mixed types.Model constructor consistency align with Laravel 11–13's stricter typing as our applications grow more dynamic, not less.How can I help you explore Laravel packages today?