interaction-design-foundation / psalm-laravel-nova
Psalm plugin for Laravel Nova
Package info
github.com/InteractionDesignFoundation/psalm-laravel-nova
Type:psalm-plugin
pkg:composer/interaction-design-foundation/psalm-laravel-nova
Requires
- php: ^8.4
- vimeo/psalm: ^7.0.0-beta19 || dev-master
Requires (Dev)
- interaction-design-foundation/coding-standard: ^1.0-RC1
- phpunit/phpunit: ^12.5 || ^13.2
Suggests
None
Provides
None
Conflicts
None
Replaces
None
This package is auto-updated.
Last update: 2026-09-02 14:28:56 UTC
README
A Psalm plugin that resolves Laravel Nova's magic: the reflective dispatch and @method annotations that static analysis cannot follow on its own. Works with Laravel apps and Nova packages.
This plugin covers Nova only. Install it in addition to
psalm/plugin-laravel, which covers the Laravel framework itself. It is not a replacement.
Nova dispatches resources, actions, filters, lenses, cards, tools, dashboards and metrics through reflection and naming conventions that live entirely inside laravel/nova. Psalm cannot see those call sites, and several Nova base classes carry @method annotations whose signatures do not describe what their subclasses actually accept. The result is a stream of false positives on an otherwise clean Nova codebase.
This plugin removes them. Its guiding principle is silence over false positives: when a type cannot be resolved with confidence, the plugin declines and lets Psalm fall back to its own inference rather than guessing.
Installation
composer require --dev interaction-design-foundation/psalm-laravel-nova
Then enable it:
vendor/bin/psalm-plugin enable interaction-design-foundation/psalm-laravel-nova
Keep psalm/plugin-laravel installed and enabled alongside it: this plugin handles Nova-specific conventions and relies on the Laravel plugin for everything else (Eloquent models, facades, container bindings).
What it does
make() calls are checked against the right constructor
Laravel\Nova\Element declares @method static static make(string|null $component = null) and Laravel\Nova\Panel declares its own differently-shaped @method make(...). Psalm stores @method annotations as pseudo methods and, during static-call resolution, gives an ancestor's pseudo method priority over the class's own real (variadic) make(). Because almost no concrete Nova field or panel declares its own @method make(...) override, every Text::make('Label', 'column') resolves against Element's single-optional-parameter signature and reports TooManyArguments.
NovaMakeSignatureHandler gives each class a make() signature derived from its own constructor.
Resource query methods are narrowed to the resource's model
Nova's Resource::indexQuery(), relatableQuery() and friends receive a Builder that Psalm sees as Builder<Model>. NovaResourceQueryMethodHandler narrows it to the resource's concrete model, resolved in two steps:
- the
@extends \Laravel\Nova\Resource<ConcreteModel>template binding, including when inherited transitively through intermediate base classes; - the
public static $model = SomeModel::classconvention property, read from the AST when no template binding resolves.
The property value is validated (it must exist and be a genuine Model subclass) before narrowing, so a typo never produces a confidently wrong type.
$this->when(...) no longer poisons fields()
Illuminate\Http\Resources\ConditionallyLoadsAttributes::when() returns mixed, which degrades the inferred element type of every array literal built with it, including Nova's fields() and actions(). NovaWhenReturnTypeHandler introspects the argument and returns the closure's return type (or the value's type) unioned with MissingValue. A literal true/false condition drops the dead branch. When the type cannot be resolved, the provider declines rather than leaking a raw Closure into the union.
Reflectively dispatched members are not reported as unused
Under findUnusedCode="true", members Nova reaches by reflection have no visible call site. NovaSuppressHandler covers the ones that stubs cannot express, mirroring what Psalm\LaravelPlugin\Handlers\SuppressHandler does for Laravel:
PossiblyUnusedMethodon an action'shandle(), which Nova dispatches throughmethod_exists()and the container rather than a base-class declaration;PossiblyUnusedMethodon a resource's open-endedrelatable{FieldName}()query methods;PossiblyUnusedMethodon the Gate methods (viewAny,view,create, …) of the policy a resource names inpublic static $policy, which Nova reaches viaauthorizedTo()→Gate::callPolicyMethod();NonInvariantPropertyTypeon$policyitself, but only for declarations compatible with the stub'sstring—public static int $policystill raises.
Hook methods that override a base declaration (fields(), apply(), calculate(), …) already inherit the parent's "used" status from the stubs, so they need no suppression.
Nova stubs
The plugin ships stubs for Action, Field, FieldElement, Element, PartitionResult, Panel, Resource, Filterable and Stack that fix vendor signatures Psalm cannot resolve. Resource is templated, so a resource can declare its model with @extends:
/** @extends \Laravel\Nova\Resource<\App\Models\User> */ final class User extends Resource { public static $model = \App\Models\User::class; }
The stubs are registered by the plugin itself; no <stubs> entry is needed in psalm.xml.
FieldElement's visibility callbacks (showOnIndex(), showOnDetail(), hideFromIndex(), …) are narrowed through a bounded template rather than a fixed union, so a closure typed against the resource's own model (fn(NovaRequest $request, Post $post): bool) is accepted instead of being rejected as too narrow. This is a deliberate trade-off, and it is wider than just wrong-model confusion: any type consistent with the bound (Model|Fluent|array<array-key, mixed>|object) is accepted for the resource parameter, so a closure typed against the wrong model (fn(NovaRequest $request, Comment $comment) on a field that only ever appears on Post) still type-checks, and so does one typed stdClass, DateTimeImmutable, or an unrelated array shape. Wrong request classes, wrong return types and wrong arity are still reported. This also makes the plugin stricter than Nova's own docblocks for the setters Nova types with a bare mixed second parameter (Nova only narrows some of them itself, via @phpstan-param): the bound now catches a resource parameter typed as something unrelated (e.g. int) that Nova's own bare mixed wouldn't have flagged.
canSee() is narrowed to NovaRequest for every Field-derived class, but not through a stub: it's declared only on the AuthorizedToSee trait, which Field/FieldElement merely inherit, and a stub cannot override a method the stubbed class only inherits from a used trait. The plugin rewrites it programmatically post-populate instead, scoped to the Field hierarchy only — Tool, Dashboard, Filters\Filter and Menu\* share the same trait but can receive a plain Illuminate\Http\Request at runtime, so their canSee() stays wide.
Filterable::filterable()'s query parameter accepts either a concrete Illuminate\Database\Eloquent\Builder or a Relation, since Nova passes a Relation for relationship-index requests. Typing a closure against only one of the two type-checks even for a field that's reachable through both request kinds — type against the shared Illuminate\Contracts\Database\Eloquent\Builder contract instead if a field needs to be safe against both.
Requirements
- PHP 8.4+
- Psalm 7.0+
laravel/nova is not a dependency: the plugin relies on its own stubs and resolves everything else from the analysed codebase.
Contributing
Pull requests are welcome. Run the checks before opening one:
composer cs:fix # auto-fix both
composer psalm
License
MIT. Copyright © The Interaction Design Foundation.