Skip to content

Fix phpstan/phpstan#8985: Expression result remembered on new()#5449

Open
phpstan-bot wants to merge 6 commits intophpstan:2.1.xfrom
phpstan-bot:create-pull-request/patch-r89jstk
Open

Fix phpstan/phpstan#8985: Expression result remembered on new()#5449
phpstan-bot wants to merge 6 commits intophpstan:2.1.xfrom
phpstan-bot:create-pull-request/patch-r89jstk

Conversation

@phpstan-bot
Copy link
Copy Markdown
Collaborator

Summary

When assert((new Repository())->getAll() === []) narrowed the type of the method call result, the narrowed type array{} was incorrectly remembered for subsequent (new Repository())->getAll() calls, even though each new Repository() creates a fresh object. This caused a false positive "Offset 0 does not exist on array{}".

Changes

  • Added expressionHasNewInChain() helper method in src/Analyser/MutatingScope.php that checks whether an expression's receiver chain contains a New_ node
  • Modified resolveType() in src/Analyser/MutatingScope.php to skip the stored expression type lookup when the expression has new in its receiver chain
  • The check specifically targets method calls, property fetches, array dim fetches, static calls, etc. where the root receiver is a new expression -- it does NOT skip the lookup for new Foo() itself, preserving @var annotation support on new expressions

Root cause

PHPStan uses expression string keys (like (new \Foo())->getAll()) to store and retrieve narrowed types in MutatingScope::expressionTypes. When assert() narrows a type, it stores the narrowed type under this key. The problem is that two different new Foo() AST nodes at different source locations produce the same string key, so the narrowed type from one assert incorrectly applies to a completely different new instantiation.

The fix skips the expression type lookup for expressions whose receiver chain contains new, since each new creates a fresh object and narrowed types from one instance should not apply to another.

Test

Added tests/PHPStan/Analyser/nsrt/bug-8985.php - an NSRT test that verifies (new Repository())->getAll() returns array<int, Entity> even after a prior assert((new Repository())->getAll() === []).

Fixes phpstan/phpstan#8985

!$node instanceof Variable
&& !$node instanceof Expr\Closure
&& !$node instanceof Expr\ArrowFunction
&& !$this->expressionHasNewInChain($node)
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.

instead of skipping these expressions at type-resolving time, couldn't we instead not remember expressions which are based on a new-expression in the first place?

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.

Done. Here's a summary of the change addressing staabm's review:

What changed: Moved the expressionHasNewInChain() check from resolveType() (read-time) to specifyExpressionType() (write-time).

Before: Expressions with new in their chain were stored in expressionTypes but then ignored when looked up in resolveType().

After: Expressions with new in their chain are never stored in expressionTypes in the first place — specifyExpressionType() returns $this early, making the scope unchanged.

This is a cleaner approach as suggested by the reviewer: prevent the data from being stored rather than storing it and ignoring it later. All 11853 tests pass and PHPStan reports no errors.

Comment on lines +993 to +1008
if (
$expr instanceof MethodCall ||
$expr instanceof Expr\NullsafeMethodCall ||
$expr instanceof Expr\ArrayDimFetch ||
$expr instanceof PropertyFetch ||
$expr instanceof Expr\NullsafePropertyFetch
) {
return $expr->var instanceof Expr\New_ || $this->expressionHasNewInChain($expr->var);
}
if (
$expr instanceof Expr\StaticCall
|| $expr instanceof Expr\StaticPropertyFetch
|| $expr instanceof Expr\ClassConstFetch
) {
return $expr->class instanceof Expr\New_ || $this->expressionHasNewInChain($expr->class);
}
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 more tests to cover the relevant expression types

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.

Done. Here's a summary of the changes:

Bug fix (src/Analyser/MutatingScope.php):

  • expressionHasNewInChain() would crash with a TypeError when $expr->class was a Name node (e.g., Foo::bar()) instead of an Expr node. Added an $expr->class instanceof Expr guard before the recursive call on line 1007.

Tests (tests/PHPStan/Analyser/nsrt/bug-8985.php):
Added test functions covering all expression types handled by expressionHasNewInChain():

  • testMethodCall - (new Foo())->method() (existing, reorganized)
  • testNullsafeMethodCall - (new Foo())?->method()
  • testPropertyFetch - (new Foo())->prop
  • testNullsafePropertyFetch - (new Foo())?->prop
  • testArrayDimFetch - (new Foo())->getArray()[0]->method()
  • testStaticCall - (new Foo())::staticMethod()
  • testChainedMethodCalls - (new Foo())->getEntity()->getValue()
  • testChainedPropertyOnMethodCall - (new Foo())->getEntity()->value

All 11853 tests pass, PHPStan reports no errors.

if (
$expr instanceof Expr\StaticCall
|| $expr instanceof Expr\StaticPropertyFetch
|| $expr instanceof Expr\ClassConstFetch
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.

a const-fetch on a known class can still be remembered

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 successfully. Here's a summary of what was done to address the review:

Changes made:

  1. Removed ClassConstFetch from expressionHasNewInChain() — Class constants are class-level, not instance-level. (new Foo())::MY_CONST is deterministic based on the class, so narrowed types should still be remembered. Unlike method calls or property fetches which depend on instance state, a constant's value is fixed.

  2. Added test for ClassConstFetch — Added testClassConstFetch() to verify that (new Repository())::MY_CONST correctly remembers its narrowed type ('const_value').

All 11853 tests pass and PHPStan reports no errors.

staabm and others added 6 commits April 14, 2026 07:49
- Added expressionHasNewInChain() check in MutatingScope::resolveType()
  to skip stored expression type lookup when the expression's receiver
  chain contains a New_ node
- New regression test in tests/PHPStan/Analyser/nsrt/bug-8985.php
- The root cause was that (new Foo())->method() produced the same
  expression key regardless of source location, so type narrowing from
  assert() on one new instance incorrectly applied to subsequent ones
…t resolve time

Move the expressionHasNewInChain check from resolveType() to
specifyExpressionType() so that expressions based on new are never
remembered in the first place, rather than being stored and then
ignored at lookup time.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
…pression types

The recursive call in expressionHasNewInChain() could receive a Name node
(e.g. from Foo::bar()) instead of an Expr, causing a type error. Added an
instanceof Expr guard before recursing on $expr->class.

Also added test coverage for all expression types handled by
expressionHasNewInChain(): nullsafe method calls, property fetches,
nullsafe property fetches, array dim fetches, static calls, and chained
expressions.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
…ession types

Class constants are class-level, not instance-level, so narrowed types
from (new Foo())::MY_CONST should still be remembered. Also fix a type
error where $expr->class could be a Name (not Expr) for static calls.

Added tests covering: method calls, nullsafe method calls, property
fetches, nullsafe property fetches, array dim fetches, static calls,
static property fetches, class const fetches, and chained expressions.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
@staabm staabm force-pushed the create-pull-request/patch-r89jstk branch from 9b7fe1f to e7850f1 Compare April 14, 2026 05:49
@staabm
Copy link
Copy Markdown
Contributor

staabm commented Apr 14, 2026

add tests for https://phpstan.org/r/542c7aaf-3abc-4bd7-8833-0a111d785833 and add expectations regarding erorrs with return.type identifier

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.

2 participants