Skip to content

[6.x] Support @param-closure-this docblock tag#11853

Draft
alies-dev wants to merge 6 commits into
vimeo:6.xfrom
alies-dev:feat/11851-param-closure-this
Draft

[6.x] Support @param-closure-this docblock tag#11853
alies-dev wants to merge 6 commits into
vimeo:6.xfrom
alies-dev:feat/11851-param-closure-this

Conversation

@alies-dev

Copy link
Copy Markdown
Contributor

Adds support for @param-closure-this <type> $param, which binds $this inside a Closure or arrow-function argument to a caller-specified type. PHPStan has shipped this since March 2024 (commit), and phpstan/phpdoc-parser already recognizes the token. Closes #11851.

The tag exists in source today in widely used packages. Laravel's Macroable::macro (every facade, Collection, Builder, Response, Route, Request) and the eleven Manager::extend siblings (AuthManager, CacheManager, FilesystemManager, etc.) all ship it. Carbon's Macro trait carries it on five files. Pest's test/it/beforeEach/afterEach use it to bind $this to TestCall. Without Psalm honoring the tag, every $this->... call inside those closures trips InvalidScope.

At the call site, ArgumentsAnalyzer resolves the parameter's closure_this_type against self, static, and template parameters and stamps the resolved Union as a psalm-closure-this-type attribute on the Closure / ArrowFunction node. self resolves to the declaring class (via declaring_method_ids), so an inherited Base::run with @param-closure-this self still binds to Base when called as Child::run. static resolves to the runtime called class. ClosureAnalyzer reads the attribute, overrides $this in the closure body's use_context, and FunctionLikeAnalyzer calls setFQCLN on the inner StatementsAnalyzer so closures passed at top level pass the InvalidScope check. Property fetches inside the bound closure resolve against the bound class storage rather than the caller's class.

The diff is ~300 LoC (~150 of analyzer code, ~150 of tests), matching the issue's estimate. New storage field on FunctionLikeParameter mirrors $out_type. New handleParamClosureThis in FunctionLikeDocblockScanner mirrors handleParamOut. Tag is registered in DocComment::PSALM_ANNOTATIONS and recognized via combined_tags for the psalm- / phpstan- prefixes.

Tests in tests/ParamClosureThisTest.php cover the documented use cases (explicit class, arrow function, static on a Macroable-style helper, $this literal, @phpstan- and @psalm- aliases, binding from inside a method, self resolving to the declaring class on inherited static methods, static resolving to the called class) plus two invalid-code cases (caller's property is not visible inside the bound closure, undefined method on the bound class). Psalm self-analysis is clean. The 76 InternalCallMapHandlerTest failures in the full suite reproduce on unmodified origin/6.x and are unrelated environmental mismatches.

Marked draft because I'd like maintainer review on (a) the AST-attribute approach for plumbing the bound type from ArgumentsAnalyzer into ClosureAnalyzer, and (b) the declaring_method_ids lookup for self resolution. Both follow patterns already used in the codebase (@param-out for storage, @psalm-self-out for docblock parsing, attribute lookup is used elsewhere in PHP-Parser-based plugins) but feedback welcome.

alies-dev added 5 commits May 18, 2026 18:23
Binds $this inside a closure or arrow-function argument to a caller-specified
type. Mirrors PHPStan's @param-closure-this and is already shipped by Laravel
(Macroable::macro, Manager::extend), Carbon, and Pest. Without this tag,
closure bodies that reference $this trip InvalidScope even when the receiving
method binds the closure via Closure::call() / Closure::bind().

Implementation:

- Parse @param-closure-this, @psalm-param-closure-this, @phpstan-param-closure-this
  in the docblock parser; store on a new FunctionLikeParameter::$closure_this_type
  field next to $out_type.
- At the call site, ArgumentsAnalyzer resolves the param's closure_this_type
  against self / static / template parameters (self resolves to the declaring
  class via declaring_method_ids so inherited static methods bind correctly;
  static resolves to the runtime called class) and stamps the resolved Union
  as the psalm-closure-this-type attribute on the Closure / ArrowFunction
  PHP-Parser node.
- ClosureAnalyzer reads the attribute, overrides $this in the closure body
  use_context, and FunctionLikeAnalyzer calls setFQCLN on the inner
  StatementsAnalyzer so top-level closures pass the InvalidScope check.
- Property fetches inside the bound closure resolve against the bound class
  storage rather than the caller's class.

Closes vimeo#11851
- TypeExpander now receives parent_class (from declaring class storage) and
  the called class's final flag, so @param-closure-this parent and final
  classes expand correctly.
- TypeTokenizer in handleParamClosureThis now receives the declaring class's
  name and parent_class so compound expressions involving self/parent in the
  tag (e.g., self::TYPE_ALIAS) tokenize correctly during scanning.
- TemplateStandinTypeReplacer now receives $context->self as $calling_class,
  matching the @param-out pattern.
- ClosureAnalyzer validates that the bound class actually exists in storage
  before threading it through; an unknown class falls back to the unbound
  path instead of corrupting the closure's FQCLN.
- addContextProperties keeps the pre-existing $statements_analyzer->getParentFQCLN()
  for unbound closures, isolating the bound-class parent_class lookup to the
  bound path.
- Tests: cover parent, self with a class constant in scope, and the inheritance
  cases from the previous commit.
- Parser now strips the leading ... from a variadic param name (e.g.,
  '@param-closure-this Bound ...$cbs') after stripping the optional &. Without
  this, the parameter-name lookup in handleParamClosureThis searched for the
  literal '..cbs' against storage names like 'cbs' and never matched, so the
  hint was silently dropped for variadic callbacks.
- Tests: cover variadic closure binding and class-generic template binding
  (the latter pins down the case where T is a class-level template, which
  unlike free-function templates is already resolved at arg-evaluation time).

Free-function template binding (e.g. '@template T' on a function with
'@param-closure-this T $cb') remains a known limitation in this PR because
template inference for free-function params runs in checkArgumentsMatch
after analyze() has already evaluated the closure body.
- ArgumentsAnalyzer now nulls the psalm-closure-this-type AST attribute on
  every Closure/ArrowFunction arg before deciding whether to stamp the
  hint, so a prior bound-call to the same closure node (or a previous
  method-resolution candidate) cannot leak its binding into a subsequent
  unbound code path. Adds a regression test.
- Docs: announce the @phpstan-param-closure-this alias and list parent
  among the supported bound-type forms.
@alies-dev alies-dev changed the title Support @param-closure-this docblock tag [6.x] Support @param-closure-this docblock tag May 18, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Support @param-closure-this docblock tag (Laravel, Carbon, Pest already ship it)

1 participant