Skip to content

Type $this in a Closure::bind() closure from the bound object - #6678

Open
zonuexe wants to merge 2 commits into
phpstan:2.3.xfrom
zonuexe:fix/closure-bind-this-from-new-this
Open

zonuexe wants to merge 2 commits into
phpstan:2.3.xfrom
zonuexe:fix/closure-bind-this-from-new-this

Conversation

@zonuexe

@zonuexe zonuexe commented Oct 5, 2026

Copy link
Copy Markdown
Contributor

This PR makes $this inside a Closure::bind() closure the type of the object that is actually bound ($newThis), refined by $newScope, instead of the scope class. Until now the scope class replaced the bound object's type whenever a scope was given.

What was wrong

  • Closure::bind(fn () => $this, null, Foo::class) typed $this as Foo, but PHP has no $this there ("Using $this when not in object context").
  • Closure::bind(fn () => $this->im(), new NoParent(), Foo::class) was silent, but PHP fails with "Call to undefined method NoParent::im()".

Cause

The Closure::bind() scope factory in StaticCallHandler took $this from $newThis, then overwrote it with the $newScope class (or the object type of the class-string) as soon as a scope argument was present. The native $this type and the scope classes used for visibility and self:: were already right; only the PHPDoc $this type ignored the bound object.

New behaviour

  • No $newThis, or a null one: no $this, whatever the scope.
  • object or mixed bound into Foo::class: Foo, so the hydrator idiom Closure::bind(fn () => $this->secret, $object, Foo::class) keeps working.
  • A SubFoo bound into Foo::class: SubFoo.
  • A Foo bound into SubFoo::class: SubFoo.
  • An unrelated NoParent bound into Foo::class: NoParent, so undefined methods and properties are reported.
  • $this of the enclosing class bound into an unrelated class scope: $this(Outer) is kept. Into an interface scope: $this(Outer)&FooInterface.
  • Foo|NoParent bound into Foo::class: Foo|NoParent. Each member of the union is refined on its own, so a member that cannot be an instance of the scope class is not silently dropped.
  • ?Foo: stays Foo|null, as closure-bind-nullable-this.php already pins.
  • A 'static' scope, or a class-string that names no known class: was *ERROR*, now the bound object's type.
  • A static closure: still no $this. PHP itself returns null with a warning when an instance is bound to a static closure, so the rows without $this match PHP.

Trade-off

A member of the bound object's type that may be an instance of the scope class is assumed to be one. object or mixed becomes the scope class, which keeps the hydrator idiom working; at runtime a non-Foo object there would only produce an undefined-property warning. Likewise a Foo bound into SubFoo::class is typed as SubFoo. The native $this type keeps the unrefined bound-object type.

This is the same result as before this PR for these cases, so their existing false positives and false negatives stay: a guard such as !$this instanceof Foo or $this instanceof SubFoo is reported as always true (and the code after it as unreachable), and an unguarded $this->secret or $this->onlyInSubFoo() on an object that turns out not to be the scope class goes unreported. With treatPhpDocTypesAsCertain: false the guards are not reported, because the native $this is not narrowed. Runtime behaviour: https://3v4l.org/dhunm#v, PHPStan output: https://phpstan.org/r/51fb207e-3211-4a84-8223-34fd75956707

Related

Closure::call() already takes $this from the object it is called with. PHPStan does not re-analyse bindTo() closure bodies with a new $this at all. The scope factory still reads the Closure::bind() arguments by position; named-argument support for Closure::bind() (marking the closure in ClosureBindArgVisitor, reordering the arguments in the factory, the lookup in ParametersAcceptorSelector) is being split out of #4081 into a separate PR.

The native C++ twin in turbo-ext/src/StaticCallHandler.cpp gets the same change, with a walk-trace fixture covering each path.

This is split out of #4081, whose self:: instance-call check relies on $this being the bound object rather than the scope class.

zonuexe and others added 2 commits October 6, 2026 00:36
The Closure::bind() scope factory took $this from $newThis but then
overwrote it with the $newScope class whenever a scope was given, so a
closure bound to null or to an unrelated object still saw $this as an
instance of the scope class: `Closure::bind(fn () => $this, null,
Foo::class)` typed $this as Foo although PHP has no $this there, and
`Closure::bind(fn () => $this->im(), new NoParent(), Foo::class)` was
silent although PHP fails with a call to undefined method NoParent::im().

$this is now the bound object's type, refined by the scope: each member
of $newThis's type that may be an instance of the scope class is
intersected with it, a member that cannot be one is kept as it is.

- no or null $newThis: no $this, whatever the scope
- `object` (or mixed) $newThis: the scope class, so the hydrator idiom
  `Closure::bind(fn () => $this->secret, $object, Foo::class)` keeps
  reading Foo's private members
- a subclass instance bound into its parent's scope: the subclass
- an unrelated object: that object
- a union like Foo|NoParent: Foo|NoParent
- a nullable $newThis: stays nullable, as closure-bind-nullable-this.php
  pins it

The members are refined one by one because intersecting the whole type
drops every member that cannot be an instance of the scope - Foo|NoParent
would become Foo and ?Foo would become Foo - turning a possible runtime
error into a silently narrower $this.

A member that may be an instance of the scope class is assumed to be one:
`object` or mixed becomes the scope class, and a Foo bound into
SubFoo::class becomes SubFoo. At runtime a non-Foo object in the hydrator
idiom only gets an undefined-property warning; the native $this keeps the
unrefined type.

The scope classes (accessibility, self::) and the native $this type,
which already came from $newThis only, are unchanged. A 'static' scope or
a class-string naming no class no longer turns $this into *ERROR*: the
bound object's type is kept.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
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.

1 participant