Skip to content

Treat trait-declared properties as initialized when promoted by an inherited constructor#6085

Merged
staabm merged 3 commits into
phpstan:2.2.xfrom
phpstan-bot:create-pull-request/patch-jjqnkhc
Jul 22, 2026
Merged

Treat trait-declared properties as initialized when promoted by an inherited constructor#6085
staabm merged 3 commits into
phpstan:2.2.xfrom
phpstan-bot:create-pull-request/patch-jjqnkhc

Conversation

@phpstan-bot

Copy link
Copy Markdown
Collaborator

Summary

When a class re-declares a property through a trait while a parent class already promotes that same property in its constructor, PHPStan reported a false positive that the property was uninitialized (property.uninitializedReadonly), even though the inherited constructor always initializes it.

Changes

  • src/Node/ClassPropertiesNode.php: in getUninitializedProperties(), after fetching the native property reflection, treat the property as initialized ($is = TrinaryLogic::createYes()) when the reflection reports the property as promoted and its declaring class differs from the current class. A promoted property is always assigned by its constructor, and when it lives in a parent class it is the parent constructor's responsibility.
  • Regression tests:
    • tests/PHPStan/Rules/Properties/data/bug-14983.php + MissingReadOnlyPropertyAssignRuleTest::testBug14983 (native readonly, including a nested-trait and a three-level hierarchy variant, plus a genuinely-uninitialized trait property that must still error).
    • tests/PHPStan/Rules/Properties/data/bug-14983-phpdoc.php + MissingReadOnlyByPhpDocPropertyAssignRuleTest::testBug14983 (@readonly PHPDoc sibling).
    • tests/PHPStan/Rules/Properties/data/bug-14983-uninitialized.php + UninitializedPropertyRuleTest::testBug14983 (plain non-readonly property.uninitialized sibling).

Root cause

ClassPropertiesNode builds its property list from the AST it traverses for the analysed class. When a child uses a trait that declares a property, that property appears in the child's own property list with isPromoted() === false (it is the trait's plain declaration). The child has no constructor body of its own, so collectUninitializedProperties() cannot find any PropertyInitializationExpr for it and the property is reported as uninitialized. However, PHP merges the trait property with the parent's promoted constructor property: native reflection resolves it to the parent class and reports it as promoted, i.e. always initialized by the inherited constructor. The fix consults the native reflection to recognise this case.

Because getUninitializedProperties() is shared, the single fix covers all three property-initialization rules that consume it — the pattern was identical across the native readonly, PHPDoc readonly, and plain uninitialized checks.

Test

Each regression test was verified to fail before the fix (the parent-promoted trait property was flagged as uninitialized on the child class) and to pass after it, while the genuinely-uninitialized trait property with no promoting ancestor continues to be reported.

Fixes phpstan/phpstan#14983

…herited constructor

- In ClassPropertiesNode::getUninitializedProperties(), mark a property as
  initialized when its native reflection is promoted and declared in a parent
  class. When a child class re-declares a property through a trait, PHP resolves
  the property to the parent's promoted constructor parameter, so it is always
  initialized by the inherited constructor.
- Fixes the false positive for the native readonly rule
  (property.uninitializedReadonly), and the same fix flows through the shared
  getUninitializedProperties() to the @readonly PHPDoc rule
  (property.uninitializedReadonlyByPhpDoc) and the plain uninitialized property
  rule (property.uninitialized).
- Genuinely uninitialized trait properties with no promoting ancestor still
  report correctly.
Comment thread tests/PHPStan/Rules/Properties/data/bug-14983.php Outdated
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@VincentLanglet
VincentLanglet requested a review from staabm July 22, 2026 12:58
Comment thread src/Node/ClassPropertiesNode.php Outdated
}

if (
$propertyReflection->isPromoted()

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.

is the root problem really only relevant to promoted properties?

https://phpstan.org/r/27313633-c4d8-48df-9054-dcc4e282f20d

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've addressed staabm's review comment.

The reviewer's question: "is the root problem really only relevant to promoted properties?" — with a playground link.

The answer: no. I fetched the playground and confirmed it demonstrates a case the original narrow fix missed — a property declared in a trait and initialized by a parent constructor's plain body assignment (not promotion):

class ClassA {
	use TraitA;
	public function __construct(string $property) {
		$this->property = $property; // body assignment, NOT promoted
	}
}
class ClassB extends ClassA {
	use TraitB; // re-declares $property via nested trait
}

I reproduced it: PHPStan still reported Class ClassB has an uninitialized readonly property $property because the original condition gated on $propertyReflection->isPromoted(), which is false here.

The fix: dropped the isPromoted() requirement. Native reflection already resolves the merged property's declaring class to the ancestor (ClassA) in both the promoted and body-assignment cases, so the single check that matters is whether the declaring class differs from the analysed class:

if ($propertyReflection->getDeclaringClass()->getName() !== $classReflection->getName()) {
	$is = TrinaryLogic::createYes();
}

When the declaring class differs, the property is inherited and its initialization is the ancestor's responsibility — and the ancestor's own ClassPropertiesNode still checks it there, so genuinely-uninitialized ancestors are still reported (no coverage lost, no duplicate errors).

Verification:

  • Added BodyInitClassA/BodyInitClassB (the playground case) to all three regression data files (native readonly, @readonly PHPDoc, plain uninitialized).
  • Confirmed each new case fails without the fix (uninitialized property $property on the child) and passes with it.
  • The genuinely-uninitialized UninitializedFromTrait cases still error as before.
  • Full tests/PHPStan/Rules/Properties + UnusedPrivatePropertyRuleTest suite: 377 pass. make phpstan: no errors.

Committed as 03b5bf67d and pushed.

…f promotion

The parent constructor may initialize the property via a plain body
assignment rather than promotion, so consult only the native reflection
declaring class: when it differs from the analysed class the property is
inherited and its initialization is the ancestor's responsibility.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@staabm
staabm merged commit 34db6b0 into phpstan:2.2.x Jul 22, 2026
738 of 740 checks passed
@staabm
staabm deleted the create-pull-request/patch-jjqnkhc branch July 22, 2026 16:04
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.

Uninitialized readonly property false positive with class hierarchy and traits

3 participants