Skip to content

Forget narrowed properties after a possibly impure method call when its values are not remembered - #7073

Open
westonruter wants to merge 2 commits into
phpstan:2.3.xfrom
westonruter:forget-properties-after-possibly-impure-calls
Open

westonruter wants to merge 2 commits into
phpstan:2.3.xfrom
westonruter:forget-properties-after-possibly-impure-calls

Conversation

@westonruter

@westonruter westonruter commented Nov 14, 2026 •

Copy link
Copy Markdown
Contributor

Closes phpstan/phpstan#15734

With rememberPossiblyImpureFunctionValues: false, the return value of a method that only maybe has side effects is no longer remembered, but what was narrowed about the properties of the object it was called on still is. This makes a call to such a method also invalidate those properties, as a call to a method that has side effects already does:

  • A method call ($obj->foo(), $obj?->foo()) invalidates $obj.
  • A static call within the declaring class (self::, static::, parent::) invalidates $this, or only the static properties when the method is static.

A method known to have no side effects, such as one marked @phpstan-pure, still leaves everything narrowed. So does a method declared in a built-in class, as it already does for the invalidation of volatile expressions after the call. With the option on, which is the default, nothing changes.

The change adds one condition to the invalidation in MethodCallHandler and StaticCallHandler:

|| (
	!$this->rememberPossiblyImpureFunctionValues
	&& !$hasSideEffects->no()
	&& !$methodReflection->getDeclaringClass()->isBuiltin()
)

The turbo extension's MethodCallHandler.cpp and StaticCallHandler.cpp get the same condition, reading the rememberPossiblyImpureFunctionValues slot they already have.

Scope

Only the object a method is called on is invalidated, mirroring what already happens for a method that has side effects. These keep their narrowing after a possibly impure call:

  • Objects passed as arguments, as in $walker->visit($node) followed by a check of $node->state.
  • Static properties of a class after a static call made from outside its hierarchy, since StaticCallHandler only invalidates when the scope's class is the declaring class.
  • Global state after a possibly impure function call, which would be a change to FuncCallHandler.

Aliasing is not considered either: after $other->advance(), the properties of $this stay narrowed, though $other may be $this.

Performance

With the option off, nearly every non-void $this->method() call not known to be pure now invalidates $this. Invalidating $this cannot use the key prefilter in ScopeOps, so each such call scans all narrowed expressions and conditional expressions. The benchmarks all run with the option on, so they do not measure this. hasSideEffects(), which is not memoized, is read once for the whole condition.

Tests

  • do-not-remember-possibly-impure-function-values-properties.php, added to DoNotRememberPossiblyImpureFunctionValuesTest, covers:
    • calls on $this, on another object, and through ?->, self::, static::, and parent::;
    • a static call from a static method;
    • that a pure method, a built-in method, instance properties after a static call, and local variables stay narrowed;
    • that a property asserted by a method's @phpstan-assert-if-true stays narrowed.
  • nsrt/remember-possibly-impure-function-values-properties.php is the same fixture under the default configuration, where every narrowing is kept.
  • StrictComparisonOfDifferentTypesDoNotRememberPossiblyImpureValuesRuleTest runs the rule with the option off on bug-15734.php, the reproducer from the issue, and expects no errors.

Without the change, the expectations it affects fail, and the reproducer reports both comparisons from the issue. With the PHP change reverted and only the native one in place, the tests pass with the extension loaded, so both implementations are covered. Analysing the fixtures with the option off gives identical output with and without the extension.

The full test suite passes with the turbo extension loaded, as do side-by-side.php, signature-parity.php, and smoke.php.

Open questions

  • This reuses rememberPossiblyImpureFunctionValues, whose name and documentation refer to return values. Projects that already set it to false get new errors, as the PMMP integration test shows (see the comment below). It could instead be put behind bleeding edge or given its own option.
  • FuncCallHandler could do the same for global state after a possibly impure function call. That is not part of this PR.

🤖 Generated with Claude Code using Claude Opus 5.5 (medium effort)

…ts values are not remembered

With `rememberPossiblyImpureFunctionValues: false`, the return value of a method that only maybe has side effects is no longer remembered, but what was narrowed about the properties of the object it was called on still was. So a method that changes a property it was not declared impure for left its callers with a stale type, and a comparison of that property after the call was reported as always true or always false:

    $this->state = self::STATE_READY;
    if (!$this->parseNextTag()) {
        return $this->state === self::STATE_COMPLETE; // reported as always false
    }

When the option is off, a method call and a static call within the declaring class now invalidate the object's properties, and a static call its static properties, unless the method is known to have no side effects, as they already did for a method that has them. With the option on, which is the default, nothing changes.

The native MethodCallHandler and StaticCallHandler of the turbo extension get the same condition.

The tests cover calls on `$this`, on another object, and through `?->`, `self::`, `static::`, and `parent::`, and that a pure method and a local variable are left alone. A counterpart fixture holds the default behavior, and a rule test shows the comparison above is no longer reported.

Co-Authored-By: Claude Opus 5.5 (medium effort) <noreply@anthropic.com>
Claude-Code-Session-Id: dfb24569-a61a-4404-8588-60a387d2cccb
@westonruter

Copy link
Copy Markdown
Contributor Author

🤖 Comment by Claude Opus 5.5 (medium effort)

The PMMP Tests failure is caused by this change: PocketMine-MP sets rememberPossiblyImpureFunctionValues: false, so it is the one integration project where the new behavior applies, and it now gets 16 new errors where a property is narrowed, a method not known to be pure is called, and the property is used again. The reports are what the change intends, but since projects that already opted in get new errors, this may be better placed behind bleedingEdge or a separate option; that can be done here if preferred.

Details

Job: https://github.com/phpstan/phpstan-src/actions/runs/43651082812/job/132623385937 (PocketMine-MP 5.44.3, whose phpstan.neon.dist has rememberPossiblyImpureFunctionValues: false #risky to remember these, better for performance to avoid repeated calls anyway).

Two representative cases:

  • Block::getCollisionBoxes() assigns $this->collisionBoxes, calls $this->getModelPositionOffset(), then iterates over and returns the property, which is now list<AxisAlignedBB>|null.
  • Main::startNextTest() checks $this->currentTest !== null, then calls $this->getLogger() before using $this->currentTest.

All 16 errors:

File Line Error
src/Server.php 1932 Cannot call method readLine() on ConsoleReaderChildProcessDaemon|null.
src/block/Block.php 922 Argument of an invalid type list<AxisAlignedBB>|null supplied for foreach, only iterables are supported.
src/block/Block.php 927 Method Block::getCollisionBoxes() should return list<AxisAlignedBB> but returns list<AxisAlignedBB>|null.
src/block/FlowerPot.php 104 Cannot call method asItem() on Block|null.
src/block/tile/Chest.php 100 Cannot call method removeAllViewers() on DoubleChestInventory|null.
src/entity/Entity.php 1325 Method Entity::getBlocksAroundWithEntityInsideActions() should return array<Block> but returns array<Block>|null.
src/entity/object/ExperienceOrb.php 165 Parameter #1 $entityId of method World::getEntity() expects int, int|null given.
src/entity/object/FallingBlock.php 180 Call to an undefined method Block::onHitGround().
src/entity/projectile/Projectile.php 147 Parameter #1 $pos of method World::isInLoadedTerrain() expects Vector3, Vector3|null given.
src/entity/projectile/Projectile.php 148 Parameter #1 $pos of method World::getBlock() expects Vector3, Vector3|null given.
src/player/Player.php 1181 Parameter #1 $pos of method World::getBlock() expects Vector3, Vector3|null given.
src/player/Player.php 1184 Parameter #1 $pos of method World::setBlock() expects Vector3, Vector3|null given.
tests/plugins/TesterPlugin/src/Main.php 92 Cannot call method getName() on Test|null.
tests/plugins/TesterPlugin/src/Main.php 93 Cannot call method start() on Test|null.
tools/generate-bedrock-data-from-packets.php 492 Cannot call method fromIntId() on ItemTypeDictionary|null.
tools/generate-bedrock-data-from-packets.php 494 Cannot call method fromIntId() on ItemTypeDictionary|null.

Namespaces are shortened. Each could be addressed in PocketMine-MP by marking the intervening method @phpstan-pure or by reading the property into a local variable first.

The other failures are not caused by this PR:

  • The doctrine/orm, symfony/symfony, phpstan-laravel, phpstan-dba, espocrm, invoiceninja, and openemr integration jobs fail the same way on an unrelated PR (update-tally-1.0, run 43649509349). The doctrine/orm errors, for example, are all Doctrine DBAL 4.6 deprecations.
  • Backward Compatibility fails on 2.3.x itself since a9434a5.
  • The two slower benchmarks, UnionTypeBench::benchDescribeIntersections (memory +8.3 %) and FileCacheStorageBench::benchSave (instructions +5.3 %), do not run the changed code. Benchmarks also failed on the preceding 2.3.x push.

… the issue's reproducer verbatim

A possibly impure method declared in a built-in class, such as `ArrayObject::getIterator()` called on a subclass, invalidated the object's narrowed properties when `rememberPossiblyImpureFunctionValues` is false. The invalidation of volatile expressions after the same call already exempts built-in methods, since they do not change userland properties. The new condition now exempts them too, in the native `MethodCallHandler` and `StaticCallHandler` of the turbo extension as well.

`hasSideEffects()` is no longer evaluated twice in the same condition; it is not memoized. In `StaticCallHandler`, the condition is nested once so the value can be held in a variable.

The rule test's fixture is now `bug-15734.php`, the reproducer from the issue copied verbatim, and it expects no errors. The comparison after a pure call that the old fixture also covered is already covered by the type inference fixture.

Both type inference fixtures gain cases for a method that asserts a property of `$this` with `@phpstan-assert-if-true`, for a static call made from a static method, and for calls to built-in methods.

Co-Authored-By: Claude Opus 5.5 (medium effort) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Tnky3JtcvQrtFGa2fwY9Ay
Claude-Code-Session-Id: dfb24569-a61a-4404-8588-60a387d2cccb
@westonruter
westonruter marked this pull request as ready for review November 15, 2026 03:58
@phpstan-bot

Copy link
Copy Markdown
Collaborator

This pull request has been marked as ready for review.

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.

Forget narrowed properties after a possibly impure method call when rememberPossiblyImpureFunctionValues is false

2 participants