Skip to content

Strip the final-by-keyword override from the declaring class before comparing a method's return type with $this - #6941

Merged
VincentLanglet merged 2 commits into
phpstan:2.3.xfrom
phpstan-bot:create-pull-request/patch-f3agoxc
Nov 4, 2026
Merged

VincentLanglet merged 2 commits into
phpstan:2.3.xfrom
phpstan-bot:create-pull-request/patch-f3agoxc

Conversation

@phpstan-bot

Copy link
Copy Markdown
Collaborator

Summary

A method returning $this counts as having side effects (it is clearly fluent/mutating), the same way void methods do. That worked when the receiver was $this, but not when it was an object made with new Model(). In that case PHPStan kept remembered method-call values after the call and reported "Call to method Model::setThis() on a separate line has no effect." This PR makes the out-of-class call behave the same as the in-class one.

Changes

  • src/Reflection/Php/PhpMethodReflection.php – hasSideEffects() compares the return type with new ThisType($this->declaringClass->withoutFinalByKeywordOverride()).
  • src/Reflection/Annotations/AnnotationMethodReflection.php – same fix for @method $this foo() methods. This was also broken; a test covers it.
  • src/Analyser/ArgumentsHandler.php – same fix for the "fluent $this method does not invalidate its object arguments" check. Before, the bug above hid this one. Once hasSideEffects() returned Yes, object arguments passed to a $this-returning method called on new Foo() would have been invalidated incorrectly. A test covers it.
  • turbo-ext/src/PhpMethodReflectionAccess.cpp, turbo-ext/src/ArgumentsHandler.cpp – the same changes in the native mirrors. The extension builds; the smoke test, the signature parity check and the full test suite pass with it loaded.
  • I also checked the other places that build a type from a method's declaring class: the Called*/Callback*UnresolvedMethod/PropertyPrototypeReflection classes, PhpDocsResolver::transformStaticType and MethodSignatureRule. They only transform static and never compare against $this, so they don't hit this mismatch.

Root cause

new Model() creates an ObjectType whose ClassReflection comes from asFinal() (it carries a final-by-keyword override, meaning "exactly this class"). PhpClassReflectionExtension gets the declaring class through $classReflection->getAncestorWithClassName(), which returns that same overridden reflection for methods declared in the class itself. The $this return type resolved from the PHPDoc holds the plain reflection. ObjectType::isSuperTypeOf() deliberately answers Maybe when only one side has the override. So ThisType(declaringClass)->isSuperTypeOf(returnType) was Maybe instead of Yes, and every check of the form "the return type is $this of the declaring class" failed. Dropping the override from the declaring class before building the ThisType makes the comparison about the class again, not about exactness.

Test

  • tests/PHPStan/Analyser/nsrt/bug-15320.php – the issue's playground sample, plus:
    • the call on a Model $m parameter;
    • a @method $this annotation method called on new MagicModel();
    • the case where an object argument stays remembered (5) after a fluent $this call on new Model().
  • tests/PHPStan/Rules/DeadCode/CallToMethodStatementWithoutImpurePointsRuleTest::testBug15320 – no "has no effect" error for the $this method. The error for the static method is still reported, as the issue expects.
  • Both tests fail without the fix. With only the ArgumentsHandler change reverted, the argument-invalidation assertion fails.

Fixes phpstan/phpstan#15320

🤖 Generated with Claude Code

…omparing a method's return type with `$this`

- `new Foo()` (and `$x::class === Foo::class` narrowing) produces an ObjectType whose
  ClassReflection is `asFinal()`. Methods looked up on it get that reflection as their
  declaring class, while the `$this` return type resolved from the PHPDoc holds the plain
  one. `ObjectType::isSuperTypeOf()` then answers Maybe, so a `@return $this` method was
  no longer treated as having side effects when called on a freshly instantiated object:
  remembered method call values were not invalidated and the call was reported as
  "on a separate line has no effect".
- PhpMethodReflection::hasSideEffects() and AnnotationMethodReflection::hasSideEffects()
  (`@method $this foo()`) now build the ThisType from
  `declaringClass->withoutFinalByKeywordOverride()`.
- ArgumentsHandler: same fix for the check that keeps object arguments of a fluent
  `$this`-returning method from being invalidated; it was hidden by the bug above and
  started over-invalidating once hasSideEffects() became Yes.
- Mirrored both changes in turbo-ext (PhpMethodReflectionAccess.cpp,
  ArgumentsHandler.cpp).
- Probed the other places that build a type from a method's declaring class
  (Called*/Callback* unresolved prototype reflections, PhpDocsResolver, MethodSignatureRule);
  they only transform `static`, so they don't run into this mismatch.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@VincentLanglet VincentLanglet self-assigned this Nov 4, 2026
@VincentLanglet
VincentLanglet requested a review from staabm November 4, 2026 09:39
{
$this->analyse([__DIR__ . '/data/bug-15320.php'], [
[
'Call to method Bug15320DeadCode\\Model::setStatic() on a separate line has no effect.',

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.

why this error?

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.

This is explained by

public function testOutClassStatic(): void
	{
		$m = new Model();

		assert($m->get('name') === 5);
		$m->setStatic('name', 10);
		assertType('5', $m->get('name'));
	}

By default $m->setStatic('name', 10); is considered as pure since it returns something.

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.

I answered the review question and left the code unchanged. I also added a comment in the test explaining why that error is expected, and pushed it as commit 65d34e9.

Why setStatic() is still reported:

  • A static return type doesn't mean the method has side effects. void and $this both mean the call only matters for what it changes, so PHPStan treats them as having side effects. A static method, though, can return a modified copy and leave the original object alone (a "wither"). So in PhpMethodReflection::hasSideEffects(), ThisType(Model)->isSuperTypeOf(static) is Maybe, not Yes, and the method stays "possibly pure".
  • The method body changes nothing. setStatic() is just return $this;, so the rule can confirm the method is pure.
  • The class is known exactly. The call is made on new Model(), so PossiblyPureMethodCallCollector knows no subclass can override setStatic().

Put together, discarding the result of $m->setStatic('name', 10); really does nothing, so the error is correct. The issue asks for it too ("error on line 30 is expected"). That matches the nsrt test, where testOutClassStatic keeps the remembered 5 after the call.

make tests and make phpstan both pass.

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.

I didn't push anything: the change this review calls for is already on the branch.

VincentLanglet's comment wasn't asking for a code change. It answered staabm's question ("why this error?"): a method returning static doesn't imply side effects, because it might return a new instance, like an immutable "wither". So $m->setStatic('name', 10) on a separate line, called on new Model(), really has no effect. The issue (phpstan/phpstan#15320) expects that error too.

I wrote a commit that added a comment explaining this to the test data file, but the push was rejected. Another phpstan-bot commit had already reached the branch, 65d34e96f, "Explain why the static-returning method call is still reported in the bug-15320 test". It puts the same explanation, in more detail, in CallToMethodStatementWithoutImpurePointsRuleTest::testBug15320. Mine would only have duplicated it, so I dropped it and reset my local branch to match the remote.

make tests and make phpstan both passed with my version, which differed only by a comment in the test data. After resetting, I re-ran CallToMethodStatementWithoutImpurePointsRuleTest on the branch as it now stands, and it passes. I didn't re-run the full suite or make phpstan after resetting.

… bug-15320 test

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@VincentLanglet
VincentLanglet requested a review from staabm November 4, 2026 11:16
@VincentLanglet
VincentLanglet merged commit c22de43 into phpstan:2.3.x Nov 4, 2026
2 checks passed
@VincentLanglet
VincentLanglet deleted the create-pull-request/patch-f3agoxc branch November 4, 2026 12:16
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.

"impure" is not inferred from "@return $this" when called outside class

3 participants