phpstan-laravel-actions maintained by humanik
phpstan-laravel-actions
A PHPStan extension for lorisleiva/laravel-actions.
Laravel Actions exposes every entry point as mixed ...$arguments): mixed:
public static function run(mixed ...$arguments): mixed
public static function dispatch(mixed ...$arguments): PendingDispatch
public function __invoke(mixed ...$arguments): mixed
Static analysis therefore sees nothing. Wrong argument counts, wrong argument types and lost
return types all pass silently, and @throws never propagates. This extension reads the
signature of the method each proxy actually forwards to — handle() or asJob() — and puts
that information back.
class SendInvite
{
use AsAction;
public function handle(string $email, int $teamId): bool { /* ... */ }
}
SendInvite::run('a@b.com'); // SendInvite::handle() invoked via run() with 1 argument, exactly 2 expected.
SendInvite::run(1, 'a@b.com'); // Parameter #1 $email of SendInvite::handle() invoked via run() expects string, int given.
SendInvite::dispatch('a@b.com'); // SendInvite::handle() invoked via dispatch() with 1 argument, exactly 2 expected.
$ok = SendInvite::run('a@b.com', 1); // bool, instead of mixed
Your IDE cannot read a PHPStan extension, so the same information is also available as a generated file — see IDE helper.
Installation
composer require --dev humanik/phpstan-laravel-actions
With phpstan/extension-installer the
extension registers itself. Otherwise include it manually:
includes:
- vendor/humanik/phpstan-laravel-actions/extension.neon
Requires PHP 8.2+ and PHPStan 2.1+. It has no other dependencies — Larastan is neither required nor conflicting.
What it does
Action detection
A class counts as an action when it uses the relevant Laravel Actions trait, however it got there:
class A { use AsAction; } // via the AsAction bundle
class B { use AsObject; } // a single concern
class C extends Lorisleiva\Actions\Action {} // the base class
class D extends C {} // any subclass
Detection uses ClassReflection::getTraits(recursive: true). The more obvious
hasTraitUse() walks parent classes with the raw ReflectionClass::getTraitNames(), which
reports only immediately used traits — so class C extends Action resolves to [AsAction]
and never to AsObject, and every such action would be silently ignored. ActionDetectionTest
locks this behaviour down.
Return types
| Call | Inferred type |
|---|---|
Action::run(...) |
return type of handle() |
Action::runIf(...) / runUnless(...) |
handle()'s type | Illuminate\Support\Fluent |
Action::dispatchSync(...) / dispatchNow(...) |
return type of asJob(), else handle() |
Action::makeJob(...) |
UniqueJobDecorator when the action implements ShouldBeUnique |
Action::mock() / spy() / partialMock() |
Mockery\MockInterface&YourAction |
$action(...) / $action->__invoke(...) |
return type of handle() |
make(), dispatch(), dispatchIf(), dispatchUnless(), dispatchAfterResponse() and
makeUniqueJob() already declare accurate types, so they are left to PHPStan.
The Mockery intersection is what makes MyAction::mock()->shouldReceive('handle') and
partialMock() keep both APIs visible at once.
Throw types
@throws declared on handle() / asJob() propagates through run(), runIf(),
runUnless(), the dispatch* family and $action->__invoke(), so PHPStan's
missingCheckedExceptionInThrows check works through the proxies.
Argument validation
Arguments at the call site are checked against the target method's real signature, including named arguments and variadics.
| Identifier | Reported when |
|---|---|
laravelActions.tooFewArguments |
fewer arguments than the target requires |
laravelActions.tooManyArguments |
more arguments than the target accepts |
laravelActions.argumentType |
an argument's type is not accepted by the parameter |
laravelActions.unknownNamedArgument |
a named argument matches no parameter |
laravelActions.missingHandle |
a proxy is used on an action that declares no handle() (or asJob()) |
Counts and parameter positions are reported as they were written, so the leading condition of
runIf() / dispatchIf() is included in the numbering.
All five share one prefix, so they can be silenced together:
parameters:
ignoreErrors:
- identifier: laravelActions.*
Jobs that receive a prepended argument
When an action is dispatched as a job, JobDecorator may insert an extra first argument:
public function asJob(JobDecorator $job, Team $team): void {}
SendInvites::dispatch($team); // one argument, two parameters — correct, and not reported
The extension reproduces JobDecorator::getPrependedParameters() branch for branch, including
the fact that a variadic target never takes the early return. Because the decision depends
only on the number of dispatched arguments, this is exact rather than a heuristic — correct
code is never flagged, and genuine mistakes behind the prepended argument still are.
IDE helper
Everything above is invisible to an editor: PhpStorm sees run(mixed ...$arguments): mixed
and offers nothing. The bundled generator writes the same signatures out as @method tags.
vendor/bin/laravel-actions-ide-helper
It scans app/ by default and writes _ide_helper_actions.php, re-declaring each action as
an empty stub that carries the tags:
namespace App\Actions {
/**
* @method static bool run(string $email, int $teamId)
* @method static bool|\Illuminate\Support\Fluent runIf(bool $boolean, string $email, int $teamId)
* @method bool __invoke(string $email, int $teamId)
* @method static \Illuminate\Foundation\Bus\PendingDispatch dispatch(string $email, int $teamId)
* @method static bool dispatchSync(string $email, int $teamId)
* @method static \Lorisleiva\Actions\Decorators\JobDecorator makeJob(string $email, int $teamId)
* @method static \Mockery\MockInterface&\App\Actions\SendInvite mock()
*/
class SendInvite {}
}
Exclude the generated file from static analysis and from your autoloader. It re-declares classes on purpose; PHPStan and Psalm will otherwise report them as duplicates.
parameters:
excludePaths:
- _ide_helper_actions.php
laravel-actions-ide-helper [<path>...] [options]
<path>... Directories or files to scan. Default: app
--output=<file> Helper file to write. Default: _ide_helper_actions.php
--write Inject the tags into the action sources instead
--dry-run Report what would change and write nothing; exits 1 when stale
--autoload=<file> Path to composer's autoload.php. Auto-detected by default
-q, --quiet Only report problems
-h, --help Show the usage
--dry-run is meant for CI: commit the helper file and fail the build when it drifts.
- run: vendor/bin/laravel-actions-ide-helper --dry-run
Writing into the action files instead
--write skips the separate file and puts the tags into each action's own docblock, between
markers so that regeneration is idempotent and anything you wrote by hand survives:
/**
* Sends an invite. This line is left alone.
*
* @method void somethingHandWritten()
*
* @laravel-actions-ide-helper-start
* @method static bool run(string $email, int $teamId)
* @laravel-actions-ide-helper-end
*/
class SendInvite
No duplicate declarations, no exclusions to configure, and every editor sees the tags — at the cost of touching your sources, so review the diff.
Types
Parameter and return types come from the target's real signature, and from its docblock where it has one — the docblock wins, since it is the more precise of the two:
/** @return Collection<int, User> */
public function handle(int $id): Collection
// @method static \Illuminate\Support\Collection<int, \App\Models\User> run(int $id)
Short names are resolved against the use statements of the file that declares the method,
which is not necessarily the action's own file when handle() is inherited.
@phpstan-param/@phpstan-return outrank @psalm-*, which outrank the plain tags, as they
do for PHPStan itself.
Limitations
- PhpStorm reports "multiple definitions exist for class" for the generated helper file.
That is inherent to the
_ide_helper*.phpconvention; use--writeto avoid it entirely. - Generics are only as good as your docblocks.
handle(): Collectionwith no@returnyields\Illuminate\Support\Collection, because that is all PHP was told. - The prepended
JobDecorator/?Batchparameter is always dropped from thedispatch*andmakeJob*tags. The runtime decides per call site, from the argument count; one@methodtag has to describe every call site, and dropping it is right for all the calls that do not pass the decorator explicitly.JobPrependParityTestpins this down. staticis resolved to the concrete action, since@method static static run()is ambiguous to read.- Abstract actions are skipped; the concrete subclass that inherits
handle()is not.
Known limitations
- Phantom
@methodtags.AsController,AsJob,AsCommand,AsListenerandWithAttributesdeclare@method/@property-readtags for hooks your class does not implement (asController(),getJobMiddleware(),$jobTries, ...). PHPStan therefore believes those are callable on any action. Validating hook declarations is out of scope here; the extension only resolves forwarding targets through native reflection, so it is itself unaffected. $action($x)and@throws. PHPStan offers no throw-type extension point for theFuncCallform of an invocation. Use$action->__invoke($x)or$action->handle($x)when exception propagation matters. Argument validation works for both forms.__invoke()targetshandle(), notasController(). The trait method literally doesreturn $this->handle(...$arguments).ControllerDecoratorrewrites routes to callasController()directly and never goes through__invoke().- Spread calls are skipped.
Action::run(...$args)has an unknowable arity. - Ambiguous callers are skipped. A caller whose type is a union of several classes has no single target signature.
- Named arguments are type-checked, but arity checking is left to PHPStan for those calls.
Development
composer install
vendor/bin/phpunit
vendor/bin/phpstan analyse
Fixtures live in tests/Fixtures; the files with deliberate errors are analysed by
RuleTestCase, and the assertType() files by TypeInferenceTestCase.
The IDE helper has its own fixture tree, tests/Fixtures/IdeHelper, so that adding a fixture
for a rule test cannot churn the generator's golden file. That file is the generator's
end-to-end test; refresh it after an intended change:
php bin/laravel-actions-ide-helper tests/Fixtures/IdeHelper --output=tests/IdeHelper/expected/actions.php.txt
Nothing under src/ may import a Laravel, laravel-actions or Mockery class — the generator
included, which is why it works through native reflection and the string constants in
src/LaravelActions.php. CI enforces it by loading the extension in a project that has none
of them installed.
License
MIT.