Skip to content

Keep property fetches on the receiver when a static method or static closure invalidates it - #6391

Merged
staabm merged 10 commits into
phpstan:2.3.xfrom
phpstan-bot:create-pull-request/patch-khmowo9
Sep 8, 2026
Merged

staabm merged 10 commits into
phpstan:2.3.xfrom
phpstan-bot:create-pull-request/patch-khmowo9

Conversation

@phpstan-bot

Copy link
Copy Markdown
Collaborator

Summary

Calling a static method from an instance method threw away every narrowed type
rooted at $this, including plain property fetches:

$this->foo = new Foo();
assertType('Foo', $this->foo);
self::assertTrue(true);       // static method
assertType('Foo|null', $this->foo);   // <- forgotten

A static method never receives $this, so it cannot change the object's own
properties. This PR keeps property fetches on the receiver alive across such a
call, while everything that can observe static state (method calls, offset
accesses on the result of a call, static property fetches) keeps being
invalidated - exactly the boundary drawn in the issue discussion.

Changes

  • src/Analyser/ScopeOps.php
    • invalidateExpressionEntries() and shouldInvalidateExpression() take a new
      optional bool $keepPropertyFetches and thread it to all three
      shouldInvalidateExpression() call sites (expression table, conditional
      expression targets, conditional expression conditions).
    • New isPropertyFetchChainOn() helper: is the entry a chain of property
      fetches (PropertyFetch/NullsafePropertyFetch, named by an Identifier or a
      plain variable) whose root prints as the invalidated expression's key?
    • New carve-out in shouldInvalidateExpression(), placed right after the cheap
      Variable fast path and before the substring gate and AST walk.
  • src/Analyser/MutatingScope.php - invalidateExpression() gains the flag and
    forwards it; src/Analyser/NodeCallbackScope.php forwards it in its override and
    in the recorded scope op.
  • src/Analyser/ExprHandler/StaticCallHandler.php - the reported bug: pass
    $methodReflection->isStatic().
  • src/Analyser/ExprHandler/MethodCallHandler.php - analogous case: a static
    method invoked with -> ($this->staticMethod(), $obj->staticMethod()) had the
    same bug on its receiver.
  • src/Analyser/ExprHandler/Helper/FuncCallScopeEffectsHelper.php - analogous
    case
    : invoking a static closure invalidated $this, although a static closure
    is never bound to $this. Non-static closures keep invalidating (they can be
    bound to $this and really can write to it).
  • turbo-ext/src/ScopeOps.cpp, turbo-ext/src/support.{h,cpp},
    build/PHPStan/Build/TurboAttributeCollector.php - the same logic ported into
    the native mirror of ScopeOps (#[ShadowedByTurboExtension]), plus two new
    class-map entries (nullsafePropertyFetch, identifier) the native helper needs.

Analogous cases probed

Fixed (each has a failing-before test):

  • nested property fetches: $this->foo->bar
  • nullsafe property fetches: $this->foo?->bar
  • dynamic property names: $this->{$name}
  • array-typed properties and their offsets: $this->arr / $this->arr['x']
  • a static method called with -> on $this or on another object
  • calling a static closure

Probed and confirmed still correctly invalidated (kept as regression tests):

  • $this->getFoo() - the method body can read static state
  • self::$staticProp - a static method really can write to it
  • a non-static method called as self::nonStatic()
  • calling a non-static closure

Probed and deliberately left alone:

  • first-class callables of static methods ($fn = self::sideEffect(...); $fn();)
    still invalidate. InitializerExprTypeResolver::createFirstClassCallable() leaves
    isStaticClosure() as maybe for them; making it yes is semantically correct
    but changes describe() output (static Closure(...)), callable variance in
    CallableTypeHelper and the Closure::bind() extensions, which is well beyond
    this fix.

Root cause

The pattern is "invalidate the whole receiver when the callee may be impure".
MutatingScope::invalidateExpression($receiver, requireMoreCharacters: true) drops
every scope entry whose expression contains the receiver, which is right for a
callee that gets the object (a non-static method, parent::__construct(), a
$this-bound closure) but far too broad for a callee that never does. The existing
carve-outs in ScopeOps::shouldInvalidateExpression() (readonly property fetches,
private properties of a different class) already encode the same idea for narrower
reasons; this adds the "the callee cannot reach the object at all" one, applied at
every site that invalidates a receiver for a callee without a $this:
StaticCallHandler, MethodCallHandler and FuncCallScopeEffectsHelper.

The carve-out is intentionally limited to property-fetch chains. Anything else
rooted at the receiver - a method call, an offset access on a call result - can
observe static state, which a static callee can change, so it keeps being
invalidated.

Test

  • tests/PHPStan/Analyser/nsrt/bug-13735.php - both playground reproducers from
    the issue verbatim (the PHPUnit-style self::assertTrue() case, and the
    non-final / final / private-static-method constructor cases).
  • tests/PHPStan/Analyser/nsrt/bug-13735b.php - the analogous cases and the
    controls listed above.

Both files fail on 2.2.x (11 wrong assertType()s) and pass with the fix.
make tests, make phpstan and make cs-fix are green; the full suite also
passes with phpstan_turbo loaded, and bin/phpstan analyse -c build/phpstan.neon src produces byte-identical output with and without the extension.
turbo-ext/tests/smoke.php, turbo-ext/tests/signature-parity.php and
turbo-ext/bin/side-by-side.php all pass.

Note

turbo-ext/src/ changed, so this needs the follow-up
make bump-turbo commit setting
TurboExtensionEnabler::EXPECTED_EXTENSION_VERSION to this change's short SHA
once it lands on the target branch.

Fixes phpstan/phpstan#13735

@staabm

staabm commented Sep 7, 2026

Copy link
Copy Markdown
Contributor

@SanderMuller please review

Comment thread tests/PHPStan/Analyser/nsrt/bug-13735b.php
@staabm

staabm commented Sep 7, 2026

Copy link
Copy Markdown
Contributor

regarding turbo errors on macos, I updated a upstream issue: shivammathur/setup-php#1116

@SanderMuller SanderMuller left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Two false negatives, both reproduced on the branch and both correct on 2.2.x. The rest of the change holds up well, including the native mirror, which I was able to test. I am not the maintainer, so the calls are yours and @ondrejmirtes's.

A static callee can reach the object, in two ways

The premise in the description is "a static method never receives $this, so it cannot change the object's own properties". A static callee gets no $this, but it can still be handed the object, or find it.

1. A static closure that takes the receiver as an argument. I varied the closure along both axes, with a body that actually writes. $this->foo starts as Foo in every row:

closure body writes 2.2.x this branch
static fn () Holder::$seen Foo|null Foo
static fn (int $i), called $fn(1) Holder::$seen Foo|null Foo
static fn (Holder $h), called $fn($this) $h->foo = null Foo|null Foo
fn (Holder $h), called $fn($this) $h->foo = null Foo|null Foo|null
fn () $this->foo = null Foo|null Foo|null

Rows 1 and 2 are the improvement, and they are clearly right: that closure cannot reach $this. Rows 4 and 5 are correct controls. Row 3 is the hole. The closure is handed $this and writes through it, and the narrowing survives.

The static method path already covers that channel on this branch:

self::mutate($this);        // still invalidates
$this->mutate($this);       // static method via ->, still invalidates
$other->mutate($other);     // still invalidates

So StaticCallHandler and MethodCallHandler handle the argument case and FuncCallScopeEffectsHelper does not.

2. A static method that reaches the object through static state. Also green on 2.2.x, also lost here:

public static ?Holder $instance = null;

public static function mutateStored(): void
{
    if (self::$instance !== null) {
        self::$instance->foo = null;
    }
}

public function reachedViaStaticProperty(): void
{
    self::$instance = $this;
    $this->foo = new Foo();
    self::mutateStored();
    assertType('Foo|null', $this->foo);   // 2.2.x: passes. This branch: 'Foo'.
}

The description says "everything that can observe static state keeps being invalidated". Property fetches on the receiver no longer are, and static state is the channel by which a static method reaches $this. Registries and singletons write self::$instance = $this routinely.

Case 1 looks like a plain gap to me, and a narrow one. Case 2 is the boundary the description draws, so where you put it is your call.

The native mirror agrees, and I checked rather than read it

ScopeOps carries #[ShadowedByTurboExtension], so this is the case where the PHP and C++ implementations can silently disagree. I built turbo-ext locally on PHP 8.5 arm64 and confirmed the extension was really active, PHPStan\Analyser\ScopeOps extending PHPStanTurbo\ScopeOps, rather than merely loaded.

  • The PR's two nsrt files plus my three probe files: 6 errors, byte-identical with and without the extension. Both false negatives above reproduce identically under the native path, so the port is faithful, including in its faults.
  • Full suite green both ways: 21324 tests / 96430 assertions, without the extension and with it.
  • bin/phpstan analyse -c build/phpstan.neon src byte-identical with and without.

The version bump is right too. EXPECTED_EXTENSION_VERSION = 'e5a7514' is the short SHA of e5a751490, the only commit touching turbo-ext/src/, which is the convention the constant's own docblock states. The [!NOTE] in the description asking for a follow-up make bump-turbo is stale, since 65955e019 already did it.

The tests do fail first

Both nsrt files against 2.2.x: 12 wrong assertType()s. The description says 11, which was right before the static:: case was added on review.

Performance

Flat. Cold corpus run, 2 interleaved rounds, on a 4524-file real-world project: CPU 141.9 / 140.6 s on base against 142.2 / 138.6 s here. maxRSS 348 to 397 MB in both arms, and the error output is identical.

That matches what isPropertyFetchChainOn() does. It returns on the first instanceof for anything that is not a property fetch. The pretty-print only runs for a real chain.

CI

Every red is accounted for and none is this change.

The four macOS Compile Turbo Extension jobs are the setup-php problem you just filed upstream. Worth adding one data point: the same .cpp built cleanly here on PHP 8.5 arm64, so nothing is hiding behind that red.

Tests with old PHPUnit (8.1, ubuntu-latest) is three data sets of IntersectionTypeTest::testIsAcceptedBy, Maybe against No. That flake has been failing on unrelated PRs all week. PHPStan (8.1, windows-latest) is red on base head.

For the maintainer

Whether case 1 blocks. I think it should. 2.2.x is correct there, and the fix looks small. Treat the receiver as invalidated when it appears among the call's arguments, the way the static-method path already does.

Whether case 2 is in scope, or a documented limitation of the carve-out.

@phpstan-bot

Copy link
Copy Markdown
Collaborator Author

Both findings addressed and pushed as two commits.

Case 1 — static closure handed the object: fixed (868f18e1e)

Reproduced exactly as reported: $fn = static function (Holder $h) { $h->foo = null; }; $fn($this); kept $this->foo narrowed.

The reason the static method path is already correct is NodeScopeResolver::processArgs() (src/Analyser/NodeScopeResolver.php:2308): for a callee with hasSideEffects()->yes(), every argument that may be an object or resource gets invalidateExpression($arg->value, true). A closure has no FunctionReflection, so $calleeReflection is null there and the whole branch is skipped. The fix mirrors that in FuncCallScopeEffectsHelper::invalidateObjectArgs(), applied only where the receiver invalidation is being weakened (src/Analyser/ExprHandler/Helper/FuncCallScopeEffectsHelper.php:97), so non-static closures are untouched.

Probing the adjacent argument channels found one more instance of the same bug and two controls, all now in bug-13735b.php:

case before after
$fn($this), static closure Foo Foo|null
$fn($this), static arrow fn Foo Foo|null
$fn($this->foo) writing $f->bar Bar Bar|null, and $this->foo still Foo
$fn(1) Foo Foo (unchanged — the improvement survives)
self::mutate($this) / self::mutateFoo($this->foo) correct correct (controls)

The property-argument row lands on the same semantics the static-method path has: the callee can change what is inside $this->foo, not which Foo $this->foo points at. Verified by stashing only the source change — lines 93, 102 and 115 fail, the two controls pass either way.

Case 2 — reached through static state: documented, not fixed (b51771485)

Your reproducer is real, but it is not a boundary the branch moved. I checked the same shape with a receiver that is not $this:

Holder::$instance = $other;
$other->foo = new Foo();
Holder::mutateStored();
assertType('Foo', $other->foo);   // passes on 2.2.x and on this branch

StaticCallHandler only ever invalidated new Variable('this'), so the static-state channel has always been untracked for every other receiver — $this was the accident, not the rule. Tracking it soundly would mean assuming any static call can reach any object, which is the feature #13735 asks to remove. So I recorded it as a limitation on isPropertyFetchChainOn()'s docblock and added doReachedViaStaticProperty() asserting today's behaviour, so a future change surfaces there. Happy to flip it if @ondrejmirtes wants the carve-out drawn tighter.

Verification

make tests (21273 tests, 96340 assertions, 97 skipped), make phpstan (no errors) and make cs are green. No turbo-ext/src/ file changed — the ScopeOps.php edit is comment-only — so EXPECTED_EXTENSION_VERSION stays at e5a7514 and no make bump-turbo follow-up is needed.

Two notes on the description, which I have not edited: the assertion count is now higher again after these tests, and the [!NOTE] asking for a make bump-turbo follow-up is stale — 65955e019 already did it, as you spotted.

@SanderMuller SanderMuller left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Both findings resolved. Case 1 is fixed and case 2 I withdraw, because your counter-argument is right and I checked it.

Case 1 is fixed, and the improvement survived

Same channel matrix as before, $this->foo starting as Foo in every row:

closure body writes 2.2.x 02766a1ad now
static fn () Holder::$seen Foo|null Foo Foo
static fn (int $i), $fn(1) Holder::$seen Foo|null Foo Foo
static fn (Holder $h), $fn($this) $h->foo = null Foo|null Foo Foo|null
fn (Holder $h), $fn($this) $h->foo = null Foo|null Foo|null Foo|null
fn () $this->foo = null Foo|null Foo|null Foo|null

Row 3 fixed, rows 1 and 2 still carry the win. That is the shape I wanted.

I confirmed your fails-before claim independently. Reverting only FuncCallScopeEffectsHelper.php to 02766a1ad makes lines 93, 102 and 115 of bug-13735b.php fail, and the two controls pass either way.

Case 2: you are right, I withdraw it

I tested the same static-state shape with three different receivers on 2.2.x:

receiver 2.2.x this branch
$this Foo|null Foo
$other, a parameter Foo Foo
$local, a local variable Foo Foo

So 2.2.x tracked that channel for $this alone, which is the accident you describe, not a rule the branch broke. Making $this behave like every other receiver is the consistent choice, and pinning it in doReachedViaStaticProperty() is the right way to leave it. My earlier framing was accurate about the one shape and wrong about what it meant.

The two cases you found while probing are real additions. The static arrow function is the same hole in different syntax. The property-argument case landing on "the callee can change what is inside $this->foo, not which Foo it points at" matches the static-method path exactly.

Turbo bookkeeping checks out

ScopeOps.php is the only shadowed class here, and the new commit adds five lines of docblock to it. FuncCallScopeEffectsHelper is not shadowed and turbo-ext/src/ references it nowhere, so e5a7514 stays correct and no make bump-turbo is due.

I re-ran the differential anyway with the extension built and active, PHPStan\Analyser\ScopeOps extending PHPStanTurbo\ScopeOps: 7 files, 12 errors, byte-identical with and without.

Gate at b51771485

Suite 21324 tests / 96430 assertions green, both with and without the extension. make phpstan clean. phpcs clean on the changed files.

Performance re-measured at this head, since invalidateObjectArgs() now runs on every impure closure call. Cold, 2 interleaved rounds, 4524-file corpus. CPU 143.3 / 137.7 s on base against 138.3 / 139.8 s here, with overlapping spreads. maxRSS 361 to 403 MB in both, error output identical. No measurable cost.

CI reds are the setup-php macOS problem you filed upstream, the IntersectionTypeTest::testIsAcceptedBy flake, and PHPStan (8.1, windows-latest) which is red on base head.

Nothing left open from my side. The description still needs its assertion count and its stale [!NOTE] fixing before merge.

@staabm staabm left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

add a test when $this is passed as argument into a method-call or static-method-call

@phpstan-bot

Copy link
Copy Markdown
Collaborator Author

Addressed the review comment; pushed as 33d275903.

Added to tests/PHPStan/Analyser/nsrt/bug-13735b.php

Five cases where the object is handed to the callee as an argument, covering both call shapes you asked about:

case call result
doLateStaticBindingGettingThisAsArgument static::mutate($this) Foo|null
doStaticMethodCalledOnThisGettingThisAsArgument $this->mutate($this) Foo|null
doStaticMethodCalledOnInstanceGettingThisAsArgument $other->mutate($this) Foo|null
doMethodCallGettingThisAsArgument $other->nonStaticMutate($this) Foo|null
doMethodCallGettingPropertyAsArgument $other->mutateFoo($this->foo) $this->foo stays Foo, $this->foo->bar becomes Bar|null

The last one lands on the same semantics as the existing self::mutateFoo($this->foo) control: the callee can change what is inside $this->foo, not which Foo it points at.

Plus a nonStaticMutate() helper on HelloWorld.

No source change was needed — and I verified the tests aren't vacuous

All five already behave correctly on this branch: the receiver invalidation is weakened for a static callee, but NodeScopeResolver::processArgs() (src/Analyser/NodeScopeResolver.php:2308) still invalidates every object-typed argument of a callee with hasSideEffects()->yes(), and $this passed as an argument goes through exactly that path.

To confirm these are real guards rather than assertions that pass no matter what, I weakened that argument invalidation itself — passing keepPropertyFetches: true at the processArgs() call site. The four new $this-as-argument assertions (lines 151, 159, 167, 175) then fail with Foo instead of Foo|null, alongside the pre-existing self::mutate($this) case at line 134. Restored, they pass.

make tests (21273 tests, 96340 assertions, 97 skipped), make phpstan (no errors) and make cs are green. Test-only commit — no turbo-ext/src/ change, so EXPECTED_EXTENSION_VERSION stays at e5a7514.

Comment thread src/Analyser/ExprHandler/Helper/FuncCallScopeEffectsHelper.php

@staabm staabm left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

rebase this fix onto 2.3.x

staabm and others added 8 commits September 8, 2026 06:49
…closure invalidates it

- Add a `$keepPropertyFetches` flag to `MutatingScope::invalidateExpression()`,
  threaded through `ScopeOps::invalidateExpressionEntries()` and
  `ScopeOps::shouldInvalidateExpression()`.
- New `ScopeOps::isPropertyFetchChainOn()` carve-out: an entry that is a chain of
  property fetches rooted at the invalidated expression (`$this->foo`,
  `$this->foo?->bar`, `$this->$name`) is kept, because a callee that never receives
  the object cannot reach it. Method calls, offset accesses and static property
  fetches keep being invalidated.
- `StaticCallHandler` passes the flag for `self::`/`static::`/`parent::` calls to a
  static method; `parent::__construct()` and non-static methods called statically
  still invalidate.
- Analogous case: `MethodCallHandler` passes it for a static method called with
  `->` (`$this->staticMethod()`, `$obj->staticMethod()`).
- Analogous case: `FuncCallScopeEffectsHelper` passes it for calling a static
  closure - a static closure has no `$this` binding.
- Ported the same logic to the native `turbo-ext/src/ScopeOps.cpp` mirror, with two
  new class references (`nullsafePropertyFetch`, `identifier`); analysis output is
  byte-identical with and without the extension.
- Probed and left alone: first-class callables of static methods still invalidate,
  because the `ClosureType` they produce is not marked as a static closure.

Closes phpstan/phpstan#13735
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
A static closure is never bound to $this, but it can still be handed the
object as an argument and write through it:

	$fn = static function (Holder $h): void { $h->foo = null; };
	$fn($this);

Keeping property fetches on the receiver across such a call forgot that
write. NodeScopeResolver::processArgs() already invalidates object
arguments for a callee with side effects - that is what keeps
'self::mutate($this)' correct on the static method path - but a closure
has no FunctionReflection, so it is skipped there. Mirror it in
FuncCallScopeEffectsHelper for the case where the invalidation of the
receiver is weakened.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
A static method can also find the object through static state
('self::$instance = $this;'). PHPStan does not track that for any
receiver - the same call has never invalidated '$other->foo' after
'HelloWorld::$instance = $other' - so this is a pre-existing limitation
the carve-out now shares, not a new one. Record it on
isPropertyFetchChainOn() and as a test of the current behaviour.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Covers the argument channel for the receiver-invalidation carve-out:
static::mutate($this), $this->mutate($this) and $other->mutate($this)
(static methods reached via -> and via late static binding), a plain
non-static method call getting $this, and a method call getting
$this->foo.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@phpstan-bot
phpstan-bot force-pushed the create-pull-request/patch-khmowo9 branch from cf4d9b2 to 4df96db Compare September 8, 2026 06:57
@staabm
staabm changed the base branch from 2.2.x to 2.3.x September 8, 2026 06:58
path: phpstan-dist
token: ${{ secrets.PHPSTAN_BOT_TOKEN }}
ref: 2.2.x
ref: 2.3.x

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The monitor has closed. All background work is finished and the outcome stands as reported:

Review addressed — no code change was warranted.

  • The octoscan alert targets .github/workflows/phar.yml (ref: 2.2.xref: 2.3.x), a line introduced by upstream commit 8ce64e0fc "Open 2.3.x". This branch touches no .github/ file: git log <2.3.x>..HEAD -- .github/ is empty, and the PR's own file list is the 12 source/test/turbo files only.
  • It surfaced because the PR base was still 2.2.x while the branch is rebased onto 2.3.x. The base has since been retargeted to 2.3.x, so the hunk is no longer in the diff.
  • The underlying line isn't a real exposure either: the ref is a literal first-party branch name, and the commit job is gated to refs/heads/2.3.x/tags, so it never runs in a PR context.

Verification at unchanged HEAD 4df96db35: make tests 21527 tests / 96712 assertions / 97 skipped, green; make phpstan no errors; both bug-13735 nsrt files pass. Working tree clean, HEAD in sync with origin — no commits to push.

@phpstan-bot

Copy link
Copy Markdown
Collaborator Author

Rebased onto 2.3.x and pushed (cf4d9b2314df96db35, force update).

What the rebase had to resolve

Four conflicts, all from 2.3.x refactorings under the change:

  • src/Analyser/NodeCallbackScope.php — change dropped entirely. 2.3.x replaced the scopeOps replay array with truthyValueExprs/falseyValueExprs; the invalidateExpression() override the PR added no longer exists. The file is now byte-identical to 2.3.x.
  • src/Analyser/ScopeOps.php. 2.3.x moved invalidateExpressionEntries()/containsExpressionToInvalidate() below getIntertwinedRefRootVariableName(), so the patch re-inserted them at the old position. Dropped the duplicates and re-threaded keepPropertyFetches through 2.3.x's copies — including its reworked conditional-holder scan, which now prints the first holder's key (self::nodeKey($firstHolder->getExpr(), …)) instead of reusing the array key. isPropertyFetchChainOn() sits right after shouldInvalidateExpression(); its comment now says nodeKey(), matching the rename.
  • turbo-ext/src/ScopeOps.cpp. Same shape — the patch added a second InvalidationQuery struct at the old location. Dropped it; keepPropertyFetches went into 2.3.x's single struct.
  • src/Analyser/ExprHandler/Helper/FuncCallScopeEffectsHelper.php — one real porting change. 2.3.x routes argument types through ArgsResult ("engine code reads argument types through this instead of re-asking the scope"), so invalidateObjectArgs() now takes ArgsResult and reads findArgResult($arg->value)->getTypeOnScope($scope, false), mirroring processArgs() (src/Analyser/NodeScopeResolver.php:3251) instead of calling $scope->getType(). findArgResult() rather than requireArgResult(): a default-value Arg that ArgumentsNormalizer synthesizes for an omitted optional parameter was never processed, and requireArgResult() throws on it.

The stale Bump expected turbo version commit was skipped and replaced by a fresh make bump-turboEXPECTED_EXTENSION_VERSION is now 91db796, the rebased SHA of the commit touching turbo-ext/src/.

Verification

  • The tests still fail first, on 2.3.x: reverting src/ to upstream/2.3.x makes 17 assertType()s across the two nsrt files wrong (12 before the rebase, on a smaller test set). Restored, they pass. I also confirmed the files aren't vacuous by corrupting an expectation and watching it fail.
  • The resource clause is still load-bearing here: deleting && (new ResourceType())->isSuperTypeOf($argType)->no() makes bug-13735b.php:132 report int instead of int|false.
  • make tests: 21527 tests, 96712 assertions, 97 skipped, green. make phpstan: no errors. make cs: clean.
  • Native mirror: built with -Wall -Wextra -Werror, tests/smoke.php prints ALL OK, signature-parity.php OK (79 methods), bin/side-by-side.php passes. Full suite with the extension loaded: same 21527/96712, green. bin/phpstan analyse -c build/phpstan.neon src byte-identical with and without the extension (31 lines each, all pre-existing dead-code noise from analysing src alone).

One thing I could not do

The PR's base branch is still 2.2.xgh pr edit --base 2.3.x fails with Resource not accessible by personal access token (updatePullRequest). The branch content is rebased onto 2.3.x and GitHub reports the PR MERGEABLE, but someone with write access on phpstan/phpstan-src needs to retarget the base, otherwise the PR diff will show all of 2.3.x's commits against 2.2.x.

@staabm staabm left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

add a test when $this is passed into a static closure like
static function () use ($this) {}

A static closure cannot say '$this' - 'static function () use ($this) {}'
is a fatal error - but it reaches the same object by capturing it under
another name, and an arrow function captures it without a 'use' clause at
all. PHPStan does not track that aliasing, so the write lands on
'$self->foo' while the caller still remembers '$this->foo'.

Gate the property-fetch carve-out on the closure having no write through a
capture, read off the invalidate expressions ClosureType already records for
both closures and arrow functions. Writes through the closure's own
parameters stay exempt: those arrive from the call site, where
invalidateObjectArgs() invalidates them.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@phpstan-bot

Copy link
Copy Markdown
Collaborator Author

Pushed as 1fc3bac62.

The requested test exposed a real hole — fixed alongside it

First, a syntax note: static function () use ($this) {} is a PHP fatal error ("Cannot use $this as lexical variable"), so I tested the shapes that actually reach the object — capturing it under another name.

All three were regressions this PR introduced. On pre-PR base they were correct; on the branch the narrowing wrongly survived:

shape base branch (before) now
use ($self), writes $self->foo Foo|null Foo Foo|null
use (&$self), writes $self->foo Foo|null Foo Foo|null
static fn () capturing $self implicitly Foo|null Foo Foo|null
use ($self) calling $self->nonStaticMutate() Foo|null Foo Foo|null

The PR's premise — "a static closure is never bound to $this" — is true, but $self = $this gets the object in anyway, and PHPStan has no alias analysis: the write lands on $self->foo while the caller remembers $this->foo. Invalidating $self doesn't help, so the carve-out itself has to stand down.

Fix

FuncCallScopeEffectsHelper now gates the carve-out on the closure having no write through a capture, read off the InvalidateExprNodes ClosureType already records. I confirmed empirically that this signal is populated for arrow functions too (uses=[] but invalidateExprs=["$self->foo"]), which is why I used it rather than getUsedVariables() — that one returns [] for arrow functions and would have missed row 3.

Writes through the closure's own parameters stay exempt, so invalidateObjectArgs() and the existing argument tests are untouched.

The improvement survives

Three controls, all of which fail on pre-PR base (so they genuinely encode the PR's win, not just the status quo) and pass here:

  • use ($self) that only reads the receiver → stays Foo. The gate is about writes, not mere capture.
  • use ($message) with a scalar → stays Foo.
  • non-static closure capturing the receiver → Foo|null, as it must.

Verification

  • The four new assertions fail without the gate (lines 162/174/186/197, Foo instead of Foo|null) and pass with it. I also corrupted an expectation to confirm the file isn't vacuous.
  • Against pre-PR base 3b094df6e, the two nsrt files now have 19 wrong assertType()s (up from 17), including the two controls at lines 210 and 222.
  • make tests: 21527 tests, 96712 assertions, 97 skipped — green, unchanged counts. make phpstan: no errors. make cs: clean.
  • FuncCallScopeEffectsHelper carries no #[ShadowedByTurboExtension] and turbo-ext/src/ references it nowhere, so no .cpp port and no make bump-turboEXPECTED_EXTENSION_VERSION stays at 91db796.

One thing I did not chase, to keep scope tight: the same shape with a non-$this receiver ($other = ...; $fn = static function () use ($other) { $other->foo = null; }; $fn();) still keeps $other->foo narrowed — but that is wrong on base too, since base only ever invalidated $this here. It's the pre-existing aliasing limitation the earlier review round already accepted for static state, not something this branch moved.

Comment on lines +112 to +113
$keepPropertyFetches = $parametersAcceptor->isStaticClosure()->yes()
&& !$this->writesThroughCapturedVariable($parametersAcceptor);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

we should simplify. don't keep property fetches as soon as the closure has captures.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pushed as a34865862.

Simplified as asked

FuncCallScopeEffectsHelper.php:106 is now two flat checks on the ClosureType:

$keepPropertyFetches = $isStaticClosure
    && $parametersAcceptor->getUsedVariables() === []
    && $parametersAcceptor->getInvalidateExpressions() === [];

writesThroughCapturedVariable() is gone (35 lines, plus the NodeFinder/is_string/array_key_exists imports). No parameter-name bookkeeping, no AST walk over the closure's writes.

The second clause is there because getUsedVariables() alone can't see an arrow function's captures — ClosureTypeResolver::buildClosureTypeForArrowFunction() passes [] for them (src/Analyser/ExprHandler/Helper/ClosureTypeResolver.php:369), so static fn () => $self->foo = null reports no used variables and only the write gives the capture away.

What the simplification costs, in the tests

Three expectations move to the conservative side, all in bug-13735b.php:

case before now
use ($self) reading the receiver Foo Foo|null
use ($message) with a scalar Foo Foo|null
$fn($this->foo), closure writes $foo->bar Foo Foo|null

The first two are your rule: a capture stands the carve-out down, whether it aliases the receiver or not. The third follows from dropping the parameter exemption — a write through a parameter is no longer told apart from a write through a capture. All three are sound, just less precise. The PR's actual win is untouched: a static closure that captures nothing still keeps $this->foo narrowed, including one taking a scalar parameter.

One coupling had to go with it

invalidateObjectArgs() used to run only when the carve-out applied. Under the new gate the resource closure (static function ($handle) { fseek($handle, 10); }) no longer keeps property fetches — fseek() has side effects, so processArgs() records an InvalidateExprNode for $handle and the closure has an invalidate expression. Leaving the coupling in place made ftell($fh) wrongly stay int. It now runs for every static closure call, independent of the carve-out; non-static closures are untouched.

Each part verified load-bearing

Deleting one clause at a time, in bug-13735b.php:

  • without getUsedVariables() === [] → lines 211, 224 report Foo (the two capture controls)
  • without getInvalidateExpressions() === [] → lines 115, 198 report Foo (static fn () capturing the receiver is the one only this clause catches)
  • without the invalidateObjectArgs() call → line 133 reports int instead of int|false

Gates

make tests 21527 tests / 96712 assertions / 97 skipped, green. make phpstan no errors. make cs clean. FuncCallScopeEffectsHelper isn't shadowed and turbo-ext/src/ doesn't reference it, so EXPECTED_EXTENSION_VERSION stays at 91db796.

…ture

Replaces the write-through-capture analysis with two flat checks on the
ClosureType: no used variables, and no invalidate expressions. A capture is
never told apart from an alias of the receiver, so any of them stands the
carve-out down; an arrow function records no used variables at all, so a
closure that writes anywhere stands it down too.

Object and resource arguments are now invalidated for every static closure
call rather than only when the carve-out applies - the argument channel is
what keeps 'ftell($fh)' invalidated across a static closure that seeks it,
and that no longer coincides with keeping property fetches.

@staabm staabm left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think thats good enough for now

@staabm
staabm merged commit b48812e into phpstan:2.3.x Sep 8, 2026
479 of 486 checks passed
@staabm
staabm deleted the create-pull-request/patch-khmowo9 branch September 8, 2026 08:01
@staabm

staabm commented Sep 8, 2026

Copy link
Copy Markdown
Contributor

merged it into 2.3.x to ease the merge handling in the affected code, which changed a lot in 2.3.x in comparison to 2.2.x

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.

don't forget scope-expressions on $this after static method call

4 participants