Skip to content

Fix phpstan/phpstan#13473: Take into account Property Hooks in __construct()#5443

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

Fix phpstan/phpstan#13473: Take into account Property Hooks in __construct()#5443
phpstan-bot wants to merge 3 commits intophpstan:2.1.xfrom
phpstan-bot:create-pull-request/patch-d6q0jee

Conversation

@phpstan-bot
Copy link
Copy Markdown
Collaborator

Summary

PHPStan incorrectly reported "Property Foo::$bar in isset() is not nullable nor uninitialized" inside a property set hook, even though the property might not yet be initialized when the hook is called (e.g., from __construct()).

Changes

  • Modified MutatingScope::enterPropertyHook() in src/Analyser/MutatingScope.php to remove the PropertyInitializationExpr for the hooked property when entering a set hook
  • Added regression test in tests/PHPStan/Rules/Variables/data/bug-13473.php and corresponding test method in tests/PHPStan/Rules/Variables/IssetRuleTest.php

Root cause

When PHPStan processes a class, the constructor is analyzed first. The constructor scope tracks which properties have been initialized via PropertyInitializationExpr entries. After the constructor finishes, rememberConstructorScope() preserves these entries in the class scope.

When property hooks are subsequently analyzed, enterPropertyHook() calls enterFunctionLike(reflection, preserveConstructorScope: true), which copies the class scope's expression types — including the PropertyInitializationExpr entries from the constructor. This caused the IssetCheck rule to believe the property was already initialized inside the set hook, even though the set hook is the very mechanism by which the property gets initialized.

The fix removes the PropertyInitializationExpr for the hooked property when entering a set hook, since the set hook may be called before the property is initialized (first assignment from constructor). Other properties' initialization state is preserved correctly. The SetNonVirtualPropertyHookAssignRule continues to work because it checks the final scope after the hook body runs (where $this->bar = $bar re-adds the initialization marker).

Test

Added a regression test reproducing the original issue: a class with a private(set) int $bar property that has a set hook checking isset($this->bar) before assigning. PHPStan should not report any errors for this code.

Fixes phpstan/phpstan#13473

- In property set hooks, the hooked property might not be initialized yet
  (e.g., when the set hook is triggered from __construct())
- Removed PropertyInitializationExpr for the hooked property from the scope
  when entering a set hook in MutatingScope::enterPropertyHook()
- Added regression test in tests/PHPStan/Rules/Variables/data/bug-13473.php
Copy link
Copy Markdown
Contributor

@VincentLanglet VincentLanglet left a comment

Choose a reason for hiding this comment

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

Do not focus on PropertyHooks, this issue can be reproduced with a generic method called in the constructor

class Foo {
    private int $bar;

    public function __construct(int $bar)
    {
        $this->setBar($bar);
    }

	public function setBar(int $bar): void 
	{
		if (isset($this->bar)) {
			throw new \Exception('bar is set');
		}
		$this->bar = $bar;
	}
}

Method called in the constructor should be considered with two different scope:

  • The place when they are called in the constructor
  • When they are called outside the constructor

When a method that assigns a property is called from the constructor,
PHPStan merges the property initialization state back into the
constructor scope. This caused a false positive when the same method
used isset() to check if the property was already initialized, because
the preserved constructor scope incorrectly marked the property as
initialized when entering the method.

The fix removes PropertyInitializationExpr entries for properties that
a method itself initializes, when the method was called from the
constructor. This generalizes the property hook fix to also work for
regular methods.

Also extracts exitPropertyInitialization() on MutatingScope to share
the logic between property hooks and regular methods.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
@phpstan-bot
Copy link
Copy Markdown
Collaborator Author

All done. Here's a summary of what was done to address the review:

Changes made

The reviewer correctly identified that the isset() false positive wasn't limited to property hooks — it also affected regular methods called from the constructor. For example:

class Foo {
    private int $bar;
    public function __construct(int $bar) {
        $this->setBar($bar);
    }
    public function setBar(int $bar): void {
        if (isset($this->bar)) { // false positive here
            throw new \Exception('bar is set');
        }
        $this->bar = $bar;
    }
}

Root cause: When setBar() is called from the constructor, processCalledMethod analyzes it and finds that it assigns $this->bar. This property initialization state is merged back into the constructor scope via mergeInitializedProperties. Later, when setBar() is analyzed as a class member, it inherits this state through preserveConstructorScope, making PHPStan incorrectly believe $this->bar is already initialized.

Fix (3 changes):

  1. MutatingScope::exitPropertyInitialization() — new public method to remove PropertyInitializationExpr for a given property, extracted from the property hook fix

  2. Property hook refactorenterPropertyHook() now calls the new method instead of inline unset calls

  3. NodeScopeResolver — When entering a non-constructor instance method, checks if the method was called from the constructor (via calledMethodResults). If so, removes PropertyInitializationExpr entries for properties that the method itself initializes. This ensures that methods which serve as property initializers don't incorrectly see their own properties as already initialized.

Verification: All 11,841 tests pass, make phpstan passes with no errors. The existing testIssetAfterRememberedConstructor test (which correctly flags isset() on always-initialized properties) continues to work.

Copy link
Copy Markdown
Contributor

@VincentLanglet VincentLanglet left a comment

Choose a reason for hiding this comment

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

I think not all PropertyInitializationExpr should be invalidate because the two tests should error with private int $bar = 1;.

What about adding a boolean to PropertyInitializationExpr in order to specify it was initialized in the constructor ?

…alues

Properties with default values (e.g. `private int $bar = 1;`) are handled
by `hasDefaultValue()` in IssetCheck, not by PropertyInitializationExpr.
Removing PropertyInitializationExpr entries for methods called from the
constructor does not affect properties with defaults — they are still
correctly flagged.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
@phpstan-bot
Copy link
Copy Markdown
Collaborator Author

The commit has been pushed. Here's a summary of what was done:

Response to review

The reviewer was concerned that removing all PropertyInitializationExpr entries when entering a method/hook could break the case where a property has a default value (private int $bar = 1;), since isset() on such a property should still be reported as an error.

Analysis shows the current approach is already correct — no code changes were needed, only test additions:

  1. PropertyInitializationExpr is never set for default values. It's only set for explicit assignments in the constructor (or promoted properties). Properties with default values like private int $bar = 1; are handled by a completely separate mechanism: the hasDefaultValue() check in IssetCheck.php (line 186).

  2. The flow for properties with defaults:

    • PropertyInitializationExpr is removed when entering the method (our fix) → correct
    • IssetCheck falls through to hasDefaultValue() check → returns true for = 1
    • Error is correctly reported: "Property ... (int) in isset() is not nullable."
  3. Adding a boolean to PropertyInitializationExpr is unnecessary because these two concerns are already cleanly separated — constructor initialization tracking (PropertyInitializationExpr) vs. default value handling (hasDefaultValue() via native reflection).

Test additions: Added FooWithDefault classes (with private int $bar = 1;) to both test data files, proving that isset() on properties with default values is still correctly flagged as an error. All 11,841 tests pass, and make phpstan reports no errors.

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