Skip to content

Type $this in Artisan::command() closures#1122

Merged
alies-dev merged 4 commits into
masterfrom
worktree-1114-artisan-command-closure-this
Jun 21, 2026
Merged

Type $this in Artisan::command() closures#1122
alies-dev merged 4 commits into
masterfrom
worktree-1114-artisan-command-closure-this

Conversation

@alies-dev

Copy link
Copy Markdown
Collaborator

Issue to Solve

Artisan::command('name', function () { $this->comment(...); }) — the closure in the default routes/console.php — reported a false InvalidScope ("Invalid reference to $this in a non-class context") out of the box for every analyzed Laravel app.

Laravel binds the callback to an Illuminate\Foundation\Console\ClosureCommand at runtime (ClosureCommand::execute() rebinds it via $this->callback->bindTo($this, $this)), so $this->comment(), $this->argument(), etc. (mixed in from Illuminate\Console\Command) are valid. Psalm saw a free closure at file scope with no $this in context.

Related

Fixes #1114

The proper fix is upstream: Psalm has no equivalent of PHPStan's @param-closure-this tag (which Larastan uses for this exact case). That tag is being added in vimeo/psalm#11853 (closes vimeo/psalm#11851). This handler is a deliberate, narrow stop-gap that auto-retires to a one-line stub annotation once the tag lands in Psalm 7 — the class docblock documents that path. Notably, #11853 and this handler independently arrived at the same mechanism (override the closure $this, then setFQCLN on the body analyzer).

Solution Description

A bound $this at file scope needs two things, which Psalm gates separately — the same pair a @var ClosureCommand $this var-docblock sets — so ConsoleClosureScopeHandler reproduces both for Artisan::command() callbacks:

  1. The $this variable's type. beforeExpressionAnalysis sets Context::$self to ClosureCommand for the span of the Artisan::command() call; ClosureAnalyzer derives the closure body's $this from it. afterExpressionAnalysis restores the original self, re-validating the node so a recycled spl_object_id (possible on Psalm's hard-fail path, where the after-hook is skipped) can never write a stale self onto an unrelated call.
  2. The structural $this->... guard. MethodCallAnalyzer rejects $this whenever StatementsAnalyzer::getFQCLN() is null. beforeStatementAnalysis calls setFQCLN(ClosureCommand) on the closure body's analyzer (exactly what the @var $this path does), clearing that guard on a per-body analyzer that is discarded when the closure finishes.

Scope: the Artisan facade static call and the generated \Artisan global alias. static closures are intentionally left erroring (they cannot be rebound at runtime). The instance form $kernel->command() is out of scope.

Verified with type tests in tests/Type/tests/ConsoleClosureScope*.phpt:

  • Happy path (closure + arrow function + \Artisan alias) asserts $this is Illuminate\Foundation\Console\ClosureCommand&static with no InvalidScope, and that argument()/option() inside the closure raise no false InvalidConsoleArgumentName/InvalidConsoleOptionName.
  • In-method test (Artisan::command() inside ServiceProvider::boot()) asserts self is restored after the call.
  • Static-closure test confirms the genuine error still fires.

Psalm self-analysis stays clean at 100% type coverage.

Checklist

  • Tests cover the change (type tests in tests/Type/)

Laravel binds the routes/console.php callback to a ClosureCommand at
runtime (ClosureCommand::execute() rebinds it via bindTo), so
$this->comment()/argument()/etc. are valid. Psalm saw a free closure at
file scope and reported a false InvalidScope out of the box for every
analysed Laravel app.

The handler types the closure body's $this as ClosureCommand and clears
the structural getFQCLN() guard for the call span, restoring scope after.
The proper fix is upstream: Psalm lacks PHPStan's @param-closure-this
(Larastan's one-liner), so this handler is a workaround pending that tag.
@alies-dev alies-dev self-assigned this Jun 21, 2026
@alies-dev

Copy link
Copy Markdown
Collaborator Author

/psalm-delta

@github-actions

Copy link
Copy Markdown
Contributor

PR delta: Base (f214c31) -> PR (786b860)

Per-app delta — Issues

App Total + Δ
coolify 1 0 1 -1
Total 1 0 1 -1
Per-app issue-type breakdown

coolify (+0/-1)

  • L-1: InvalidScope: 1 -> 0 (+0/-1)

No change (ran clean, zero delta): monica, pixelfed, bookstack, solidtime, laravel-socialite, laravel-permission, laravel-excel.

Per-app delta — Time (seconds)

No significant time change (all deltas within runner jitter).

Per-app delta — Type coverage (%)

No type-coverage changes.

Informational only — never fails the PR. Reproduce locally: bash bin/ci/delta.sh <pr-branch>.

@alies-dev alies-dev merged commit bca83dd into master Jun 21, 2026
20 checks passed
@alies-dev alies-dev deleted the worktree-1114-artisan-command-closure-this branch June 21, 2026 12:52
alies-dev added a commit that referenced this pull request Jun 21, 2026
Follow-up to #1122. That handler set Context::$self to ClosureCommand for
the whole Artisan::command() call, so self::/static:: in the *signature*
argument resolved against ClosureCommand — e.g. Artisan::command(self::SIG,
fn ...) inside a class reported a false UndefinedConstant/UndefinedMethod.

Override self only while the callback closure node itself is analysed
(record the callback arg's spl_object_id at the call, flip self when that
node is visited, restore after), leaving sibling arguments on the real
self. The callback id is not consumed on first match: the facade @method
dispatch analyses the argument more than once, so each pass re-applies the
override; the id set is reset per file to bound it and avoid spl_object_id
reuse across files.

Also restricts detection to the actual callback argument (2nd positional
or named `callback`), runs the cheap AST checks before codebase queries,
and strengthens the type tests (self::/static:: signature regression,
arrow and static-arrow forms, named-argument callback, non-global option).
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

release:feature for PRs only (used by release-drafter)

Projects

None yet

1 participant