From 6b8516e12b4338cb27fcfc87c4830c569679e1ca Mon Sep 17 00:00:00 2001 From: VincentLanglet <9052536+VincentLanglet@users.noreply.github.com> Date: Fri, 1 May 2026 18:41:17 +0000 Subject: [PATCH 1/3] Inherit PHPDoc type from parent/interface properties for promoted constructor properties - In `PhpClassReflectionExtension::createProperty()`, promoted properties skipped `PhpDocInheritanceResolver::resolvePhpDocForProperty()` entirely, so their PHPDoc type was never inherited from parent/interface properties. - Add a fallback that calls `resolvePhpDocForProperty()` when a promoted property has no local `@var` tag and no constructor `@param` tag, inheriting the PHPDoc type from the overridden property. - Priority is preserved: local `@var` > constructor `@param` > inherited type. - Tested with interfaces (property hooks), parent classes, abstract classes, multi-level inheritance, and generic interfaces with template types. --- .../Php/PhpClassReflectionExtension.php | 22 +++ tests/PHPStan/Analyser/nsrt/bug-14564.php | 142 ++++++++++++++++++ .../Properties/OverridingPropertyRuleTest.php | 7 + .../Rules/Properties/data/bug-14564.php | 106 +++++++++++++ 4 files changed, 277 insertions(+) create mode 100644 tests/PHPStan/Analyser/nsrt/bug-14564.php create mode 100644 tests/PHPStan/Rules/Properties/data/bug-14564.php diff --git a/src/Reflection/Php/PhpClassReflectionExtension.php b/src/Reflection/Php/PhpClassReflectionExtension.php index a31e70433e3..5293e4ad041 100644 --- a/src/Reflection/Php/PhpClassReflectionExtension.php +++ b/src/Reflection/Php/PhpClassReflectionExtension.php @@ -338,6 +338,28 @@ private function createProperty( } } + if ($phpDocType === null && $constructorName !== null) { + $inheritedPhpDoc = $this->phpDocInheritanceResolver->resolvePhpDocForProperty( + $declaringClassReflection, + $propertyName, + null, + ); + if ($inheritedPhpDoc !== null) { + $varTags = $inheritedPhpDoc->getVarTags(); + if (isset($varTags[0]) && count($varTags) === 1) { + $phpDocType = $varTags[0]->getType(); + } elseif (isset($varTags[$propertyName])) { + $phpDocType = $varTags[$propertyName]->getType(); + } + $phpDocType = $phpDocType !== null ? TemplateTypeHelper::resolveTemplateTypes( + $phpDocType, + $declaringClassReflection->getActiveTemplateTypeMap(), + $declaringClassReflection->getCallSiteVarianceMap(), + TemplateTypeVariance::createInvariant(), + ) : null; + } + } + if ( $phpDocType === null && $this->inferPrivatePropertyTypeFromConstructor diff --git a/tests/PHPStan/Analyser/nsrt/bug-14564.php b/tests/PHPStan/Analyser/nsrt/bug-14564.php new file mode 100644 index 00000000000..f0880fc7130 --- /dev/null +++ b/tests/PHPStan/Analyser/nsrt/bug-14564.php @@ -0,0 +1,142 @@ += 8.4 + +declare(strict_types = 1); + +namespace Bug14564Nsrt; + +use function PHPStan\Testing\assertType; + +interface A { + /** @var array */ + public array $test { get; } + + /** @var non-empty-string */ + public string $nonEmptyString { get; } + + /** @var int<1,max> */ + public int $positive { get; } +} + +// Promoted properties inherit from interface +class B implements A { + + public function __construct( + + public array $test, + + public string $nonEmptyString, + + public int $positive, + + ) { } + +} + +function test(B $b): void { + assertType('array', $b->test); + assertType('non-empty-string', $b->nonEmptyString); + assertType('int<1, max>', $b->positive); +} + +// Promoted properties inherit from parent class +class ParentClass { + /** @var array */ + public array $items; +} + +class ChildWithPromoted extends ParentClass { + + public function __construct( + public array $items, + ) { } + +} + +function test2(ChildWithPromoted $c): void { + assertType('array', $c->items); +} + +// Constructor @param overrides inherited type +class WithParam implements A { + + /** + * @param list $test + * @param non-empty-string $nonEmptyString + * @param positive-int $positive + */ + public function __construct( + public array $test, + public string $nonEmptyString, + public int $positive, + ) { } + +} + +function test3(WithParam $w): void { + assertType('list', $w->test); + assertType('non-empty-string', $w->nonEmptyString); + assertType('int<1, max>', $w->positive); +} + +// Explicit @var on promoted property overrides inherited type +class WithVar implements A { + + public function __construct( + /** @var list */ + public array $test, + /** @var non-empty-string */ + public string $nonEmptyString, + /** @var int<1,max> */ + public int $positive, + ) { } + +} + +function test4(WithVar $w): void { + assertType('list', $w->test); + assertType('non-empty-string', $w->nonEmptyString); + assertType('int<1, max>', $w->positive); +} + +// Multi-level inheritance +abstract class AbstractBase { + /** @var array */ + public array $data; +} + +class Middle extends AbstractBase { +} + +class GrandchildWithPromoted extends Middle { + + public function __construct( + public array $data, + ) { } + +} + +function test5(GrandchildWithPromoted $g): void { + assertType('array', $g->data); +} + +// Generic interface +/** + * @template T + */ +interface Container { + /** @var T */ + public mixed $value { get; } +} + +/** @implements Container */ +class StringContainer implements Container { + + public function __construct( + public mixed $value, + ) { } + +} + +function test6(StringContainer $c): void { + assertType('string', $c->value); +} diff --git a/tests/PHPStan/Rules/Properties/OverridingPropertyRuleTest.php b/tests/PHPStan/Rules/Properties/OverridingPropertyRuleTest.php index 4324a668390..b60b176a5a1 100644 --- a/tests/PHPStan/Rules/Properties/OverridingPropertyRuleTest.php +++ b/tests/PHPStan/Rules/Properties/OverridingPropertyRuleTest.php @@ -345,4 +345,11 @@ public function testFixMissingOverrideAttribute(): void $this->fix(__DIR__ . '/data/property-override-attr-missing.php', __DIR__ . '/data/property-override-attr-missing.php.fixed'); } + #[RequiresPhp('>= 8.4.0')] + public function testBug14564(): void + { + $this->reportMaybes = true; + $this->analyse([__DIR__ . '/data/bug-14564.php'], []); + } + } diff --git a/tests/PHPStan/Rules/Properties/data/bug-14564.php b/tests/PHPStan/Rules/Properties/data/bug-14564.php new file mode 100644 index 00000000000..3bea2af8f2f --- /dev/null +++ b/tests/PHPStan/Rules/Properties/data/bug-14564.php @@ -0,0 +1,106 @@ += 8.4 + +namespace Bug14564; + +interface A { + /** @var array */ + public array $test { get; } + + /** @var non-empty-string */ + public string $nonEmptyString { get; } + + /** @var int<1,max> */ + public int $positive { get; } +} + +// Regular properties inherit PHPDoc types from interface - works correctly +class B implements A { + + public array $test = [ 1 ]; + + public string $nonEmptyString = ''; + + public int $positive = -1; + +} + +// Promoted properties should also inherit PHPDoc types from interface +class C implements A { + + public function __construct( + + public array $test, + + public string $nonEmptyString, + + public int $positive, + + ) { } + +} + +// This works because types are explicitly annotated +class D implements A { + + public function __construct( + + /** @var array */ + public array $test, + + /** @var non-empty-string */ + public string $nonEmptyString, + + /** @var int<1,max> */ + public int $positive, + + ) { } + +} + +// Inheritance from parent class +class ParentClass { + /** @var array */ + public array $items; + + /** @var non-empty-string */ + public string $name; +} + +class ChildWithPromoted extends ParentClass { + + public function __construct( + public array $items, + public string $name, + ) { } + +} + +// Inheritance from abstract class +abstract class AbstractBase { + /** @var array */ + public array $data; +} + +class ConcreteWithPromoted extends AbstractBase { + + public function __construct( + public array $data, + ) { } + +} + +// Constructor @param should still override inherited type +class WithExplicitParam implements A { + + /** + * @param list $test + * @param non-empty-string $nonEmptyString + * @param positive-int $positive + */ + public function __construct( + public array $test, + public string $nonEmptyString, + public int $positive, + ) { } + +} From 0b7bf7fa199f345141cc2bfae194541b5a1b9e96 Mon Sep 17 00:00:00 2001 From: phpstan-bot Date: Fri, 1 May 2026 18:53:28 +0000 Subject: [PATCH 2/3] Merge NSRT and rule test fixture files into a single file Co-Authored-By: Claude Opus 4.6 --- tests/PHPStan/Analyser/nsrt/bug-14564.php | 115 +++++++++++++----- .../Properties/OverridingPropertyRuleTest.php | 2 +- .../Rules/Properties/data/bug-14564.php | 106 ---------------- 3 files changed, 85 insertions(+), 138 deletions(-) delete mode 100644 tests/PHPStan/Rules/Properties/data/bug-14564.php diff --git a/tests/PHPStan/Analyser/nsrt/bug-14564.php b/tests/PHPStan/Analyser/nsrt/bug-14564.php index f0880fc7130..333b4454032 100644 --- a/tests/PHPStan/Analyser/nsrt/bug-14564.php +++ b/tests/PHPStan/Analyser/nsrt/bug-14564.php @@ -2,7 +2,7 @@ declare(strict_types = 1); -namespace Bug14564Nsrt; +namespace Bug14564; use function PHPStan\Testing\assertType; @@ -17,9 +17,20 @@ interface A { public int $positive { get; } } -// Promoted properties inherit from interface +// Regular properties inherit PHPDoc types from interface class B implements A { + public array $test = [ 1 ]; + + public string $nonEmptyString = ''; + + public int $positive = -1; + +} + +// Promoted properties inherit from interface +class C implements A { + public function __construct( public array $test, @@ -32,28 +43,91 @@ public function __construct( } -function test(B $b): void { - assertType('array', $b->test); - assertType('non-empty-string', $b->nonEmptyString); - assertType('int<1, max>', $b->positive); +function test(C $c): void { + assertType('array', $c->test); + assertType('non-empty-string', $c->nonEmptyString); + assertType('int<1, max>', $c->positive); +} + +// Explicit @var on promoted property +class D implements A { + + public function __construct( + + /** @var array */ + public array $test, + + /** @var non-empty-string */ + public string $nonEmptyString, + + /** @var int<1,max> */ + public int $positive, + + ) { } + +} + +function test2(D $d): void { + assertType('array', $d->test); + assertType('non-empty-string', $d->nonEmptyString); + assertType('int<1, max>', $d->positive); } // Promoted properties inherit from parent class class ParentClass { /** @var array */ public array $items; + + /** @var non-empty-string */ + public string $name; } class ChildWithPromoted extends ParentClass { public function __construct( public array $items, + public string $name, ) { } } -function test2(ChildWithPromoted $c): void { +function test3(ChildWithPromoted $c): void { assertType('array', $c->items); + assertType('non-empty-string', $c->name); +} + +// Inheritance from abstract class +abstract class AbstractBase { + /** @var array */ + public array $data; +} + +class ConcreteWithPromoted extends AbstractBase { + + public function __construct( + public array $data, + ) { } + +} + +function test4(ConcreteWithPromoted $c): void { + assertType('array', $c->data); +} + +// Multi-level inheritance +class Middle extends AbstractBase { +} + +class GrandchildWithPromoted extends Middle { + + public function __construct( + public array $data, + ) { } + +} + +function test5(GrandchildWithPromoted $g): void { + assertType('array', $g->data); } // Constructor @param overrides inherited type @@ -72,7 +146,7 @@ public function __construct( } -function test3(WithParam $w): void { +function test6(WithParam $w): void { assertType('list', $w->test); assertType('non-empty-string', $w->nonEmptyString); assertType('int<1, max>', $w->positive); @@ -92,33 +166,12 @@ public function __construct( } -function test4(WithVar $w): void { +function test7(WithVar $w): void { assertType('list', $w->test); assertType('non-empty-string', $w->nonEmptyString); assertType('int<1, max>', $w->positive); } -// Multi-level inheritance -abstract class AbstractBase { - /** @var array */ - public array $data; -} - -class Middle extends AbstractBase { -} - -class GrandchildWithPromoted extends Middle { - - public function __construct( - public array $data, - ) { } - -} - -function test5(GrandchildWithPromoted $g): void { - assertType('array', $g->data); -} - // Generic interface /** * @template T @@ -137,6 +190,6 @@ public function __construct( } -function test6(StringContainer $c): void { +function test8(StringContainer $c): void { assertType('string', $c->value); } diff --git a/tests/PHPStan/Rules/Properties/OverridingPropertyRuleTest.php b/tests/PHPStan/Rules/Properties/OverridingPropertyRuleTest.php index b60b176a5a1..27b31fa5df8 100644 --- a/tests/PHPStan/Rules/Properties/OverridingPropertyRuleTest.php +++ b/tests/PHPStan/Rules/Properties/OverridingPropertyRuleTest.php @@ -349,7 +349,7 @@ public function testFixMissingOverrideAttribute(): void public function testBug14564(): void { $this->reportMaybes = true; - $this->analyse([__DIR__ . '/data/bug-14564.php'], []); + $this->analyse([__DIR__ . '/../../Analyser/nsrt/bug-14564.php'], []); } } diff --git a/tests/PHPStan/Rules/Properties/data/bug-14564.php b/tests/PHPStan/Rules/Properties/data/bug-14564.php deleted file mode 100644 index 3bea2af8f2f..00000000000 --- a/tests/PHPStan/Rules/Properties/data/bug-14564.php +++ /dev/null @@ -1,106 +0,0 @@ -= 8.4 - -namespace Bug14564; - -interface A { - /** @var array */ - public array $test { get; } - - /** @var non-empty-string */ - public string $nonEmptyString { get; } - - /** @var int<1,max> */ - public int $positive { get; } -} - -// Regular properties inherit PHPDoc types from interface - works correctly -class B implements A { - - public array $test = [ 1 ]; - - public string $nonEmptyString = ''; - - public int $positive = -1; - -} - -// Promoted properties should also inherit PHPDoc types from interface -class C implements A { - - public function __construct( - - public array $test, - - public string $nonEmptyString, - - public int $positive, - - ) { } - -} - -// This works because types are explicitly annotated -class D implements A { - - public function __construct( - - /** @var array */ - public array $test, - - /** @var non-empty-string */ - public string $nonEmptyString, - - /** @var int<1,max> */ - public int $positive, - - ) { } - -} - -// Inheritance from parent class -class ParentClass { - /** @var array */ - public array $items; - - /** @var non-empty-string */ - public string $name; -} - -class ChildWithPromoted extends ParentClass { - - public function __construct( - public array $items, - public string $name, - ) { } - -} - -// Inheritance from abstract class -abstract class AbstractBase { - /** @var array */ - public array $data; -} - -class ConcreteWithPromoted extends AbstractBase { - - public function __construct( - public array $data, - ) { } - -} - -// Constructor @param should still override inherited type -class WithExplicitParam implements A { - - /** - * @param list $test - * @param non-empty-string $nonEmptyString - * @param positive-int $positive - */ - public function __construct( - public array $test, - public string $nonEmptyString, - public int $positive, - ) { } - -} From a4ed3fa8aaef189d9142252e5fb7bbfb8f3ca9fb Mon Sep 17 00:00:00 2001 From: phpstan-bot Date: Sat, 2 May 2026 09:11:35 +0000 Subject: [PATCH 3/3] Resolve inherited PHPDoc metadata for promoted properties via resolvePhpDocForProperty Move the resolvePhpDocForProperty call before the metadata extraction block so promoted properties also inherit isInternal, isReadOnlyByPhpDoc, isFinal, and isAllowedPrivateMutation from parent/interface properties. The @param tag still overrides inherited types but not explicit local @var annotations. Add test for private parent property not conflicting with child's promoted property. Co-Authored-By: Claude Opus 4.6 --- .../Php/PhpClassReflectionExtension.php | 44 ++++++------------- tests/PHPStan/Analyser/nsrt/bug-14564.php | 20 +++++++++ 2 files changed, 34 insertions(+), 30 deletions(-) diff --git a/src/Reflection/Php/PhpClassReflectionExtension.php b/src/Reflection/Php/PhpClassReflectionExtension.php index 5293e4ad041..e80d956b362 100644 --- a/src/Reflection/Php/PhpClassReflectionExtension.php +++ b/src/Reflection/Php/PhpClassReflectionExtension.php @@ -301,6 +301,14 @@ private function createProperty( ); } + if ($resolvedPhpDoc === null && $constructorName !== null) { + $resolvedPhpDoc = $this->phpDocInheritanceResolver->resolvePhpDocForProperty( + $declaringClassReflection, + $propertyName, + null, + ); + } + if ($resolvedPhpDoc !== null) { $varTags = $resolvedPhpDoc->getVarTags(); if (isset($varTags[0]) && count($varTags) === 1) { @@ -326,40 +334,16 @@ private function createProperty( $isAllowedPrivateMutation = $resolvedPhpDoc->isAllowedPrivateMutation(); } - if ($phpDocType === null) { - if (isset($constructorName)) { - $resolvedConstructorPhpDoc = $declaringClassReflection->getConstructor()->getResolvedPhpDoc(); - if ($resolvedConstructorPhpDoc !== null) { - $paramTags = $resolvedConstructorPhpDoc->getParamTags(); - if (isset($paramTags[$propertyReflection->getName()])) { - $phpDocType = $paramTags[$propertyReflection->getName()]->getType(); - } + if ($constructorName !== null && ($phpDocType === null || $docComment === null)) { + $resolvedConstructorPhpDoc = $declaringClassReflection->getConstructor()->getResolvedPhpDoc(); + if ($resolvedConstructorPhpDoc !== null) { + $paramTags = $resolvedConstructorPhpDoc->getParamTags(); + if (isset($paramTags[$propertyReflection->getName()])) { + $phpDocType = $paramTags[$propertyReflection->getName()]->getType(); } } } - if ($phpDocType === null && $constructorName !== null) { - $inheritedPhpDoc = $this->phpDocInheritanceResolver->resolvePhpDocForProperty( - $declaringClassReflection, - $propertyName, - null, - ); - if ($inheritedPhpDoc !== null) { - $varTags = $inheritedPhpDoc->getVarTags(); - if (isset($varTags[0]) && count($varTags) === 1) { - $phpDocType = $varTags[0]->getType(); - } elseif (isset($varTags[$propertyName])) { - $phpDocType = $varTags[$propertyName]->getType(); - } - $phpDocType = $phpDocType !== null ? TemplateTypeHelper::resolveTemplateTypes( - $phpDocType, - $declaringClassReflection->getActiveTemplateTypeMap(), - $declaringClassReflection->getCallSiteVarianceMap(), - TemplateTypeVariance::createInvariant(), - ) : null; - } - } - if ( $phpDocType === null && $this->inferPrivatePropertyTypeFromConstructor diff --git a/tests/PHPStan/Analyser/nsrt/bug-14564.php b/tests/PHPStan/Analyser/nsrt/bug-14564.php index 333b4454032..128088db039 100644 --- a/tests/PHPStan/Analyser/nsrt/bug-14564.php +++ b/tests/PHPStan/Analyser/nsrt/bug-14564.php @@ -193,3 +193,23 @@ public function __construct( function test8(StringContainer $c): void { assertType('string', $c->value); } + +// Private property in parent should not conflict with child's property +class FooWithPrivate { + /** @var array */ + private array $items = []; +} + +class BarWithPromoted extends FooWithPrivate { + + public function __construct( + public array $items, + ) { + parent::__construct(); + } + +} + +function test9(BarWithPromoted $b): void { + assertType('array', $b->items); +}