Type $this in Artisan::command() closures#1122
Merged
Merged
Conversation
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.
Collaborator
Author
|
/psalm-delta |
Contributor
PR delta: Base (f214c31) -> PR (786b860)Per-app delta — Issues
Per-app issue-type breakdowncoolify (+0/-1)
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: |
1 task
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).
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Issue to Solve
Artisan::command('name', function () { $this->comment(...); })— the closure in the defaultroutes/console.php— reported a falseInvalidScope("Invalid reference to$thisin a non-class context") out of the box for every analyzed Laravel app.Laravel binds the callback to an
Illuminate\Foundation\Console\ClosureCommandat runtime (ClosureCommand::execute()rebinds it via$this->callback->bindTo($this, $this)), so$this->comment(),$this->argument(), etc. (mixed in fromIlluminate\Console\Command) are valid. Psalm saw a free closure at file scope with no$thisin context.Related
Fixes #1114
The proper fix is upstream: Psalm has no equivalent of PHPStan's
@param-closure-thistag (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, thensetFQCLNon the body analyzer).Solution Description
A bound
$thisat file scope needs two things, which Psalm gates separately — the same pair a@var ClosureCommand $thisvar-docblock sets — soConsoleClosureScopeHandlerreproduces both forArtisan::command()callbacks:$thisvariable's type.beforeExpressionAnalysissetsContext::$selftoClosureCommandfor the span of theArtisan::command()call;ClosureAnalyzerderives the closure body's$thisfrom it.afterExpressionAnalysisrestores the originalself, re-validating the node so a recycledspl_object_id(possible on Psalm's hard-fail path, where the after-hook is skipped) can never write a staleselfonto an unrelated call.$this->...guard.MethodCallAnalyzerrejects$thiswheneverStatementsAnalyzer::getFQCLN()is null.beforeStatementAnalysiscallssetFQCLN(ClosureCommand)on the closure body's analyzer (exactly what the@var $thispath does), clearing that guard on a per-body analyzer that is discarded when the closure finishes.Scope: the
Artisanfacade static call and the generated\Artisanglobal alias.staticclosures 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:\Artisanalias) asserts$thisisIlluminate\Foundation\Console\ClosureCommand&staticwith noInvalidScope, and thatargument()/option()inside the closure raise no falseInvalidConsoleArgumentName/InvalidConsoleOptionName.Artisan::command()insideServiceProvider::boot()) assertsselfis restored after the call.Psalm self-analysis stays clean at 100% type coverage.
Checklist
tests/Type/)