Looking to hire Laravel developers? Try LaraJobs

phpstan-laravel-actions maintained by humanik

Description
PHPStan extension for lorisleiva/laravel-actions: return types, throw types and argument validation for action classes.
Last update
2026/08/01 18:00 (dev-main)
License
Links
Downloads
0

Comments
comments powered by Disqus

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*.php convention; use --write to avoid it entirely.
  • Generics are only as good as your docblocks. handle(): Collection with no @return yields \Illuminate\Support\Collection, because that is all PHP was told.
  • The prepended JobDecorator/?Batch parameter is always dropped from the dispatch* and makeJob* tags. The runtime decides per call site, from the argument count; one @method tag has to describe every call site, and dropping it is right for all the calls that do not pass the decorator explicitly. JobPrependParityTest pins this down.
  • static is 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 @method tags. AsController, AsJob, AsCommand, AsListener and WithAttributes declare @method/@property-read tags 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 the FuncCall form of an invocation. Use $action->__invoke($x) or $action->handle($x) when exception propagation matters. Argument validation works for both forms.
  • __invoke() targets handle(), not asController(). The trait method literally does return $this->handle(...$arguments). ControllerDecorator rewrites routes to call asController() 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.