Skip to content

Inherit PHPDoc type from parent/interface properties for promoted constructor properties#5581

Closed
phpstan-bot wants to merge 3 commits intophpstan:2.1.xfrom
phpstan-bot:create-pull-request/patch-0y9k9yw
Closed

Inherit PHPDoc type from parent/interface properties for promoted constructor properties#5581
phpstan-bot wants to merge 3 commits intophpstan:2.1.xfrom
phpstan-bot:create-pull-request/patch-0y9k9yw

Conversation

@phpstan-bot
Copy link
Copy Markdown
Collaborator

Summary

When an interface or parent class declares a property with a PHPDoc type (e.g. /** @var array<string> */), regular (non-promoted) properties in implementing/extending classes correctly inherit that PHPDoc type. However, promoted constructor properties did not inherit the type, causing false positive covariance errors like "PHPDoc type array of property C::$test is not covariant with PHPDoc type array of overridden property A::$test".

This PR makes promoted properties inherit PHPDoc types from parent/interface properties when they don't have their own explicit type annotation.

Changes

  • src/Reflection/Php/PhpClassReflectionExtension.php: Added a fallback in createProperty() that calls PhpDocInheritanceResolver::resolvePhpDocForProperty() for promoted properties when no type is found from local @var or constructor @param tags. This mirrors what already happens for regular properties.

Root cause

In PhpClassReflectionExtension::createProperty(), the PHPDoc resolution logic had two branches:

  • Regular properties ($constructorName === null): Called resolvePhpDocForProperty() which merges the current property's PHPDoc with parent/interface PHPDoc
  • Promoted properties ($constructorName !== null): Only processed local @var tags and constructor @param tags, never calling resolvePhpDocForProperty()

The fix adds a third fallback after the existing @var and @param checks: if $phpDocType is still null for a promoted property, call resolvePhpDocForProperty() to inherit from the parent/interface.

The priority order is preserved correctly:

  1. Local @var on the promoted property (most explicit)
  2. Constructor @param for the parameter
  3. Inherited from parent/interface property (implicit fallback)

Analogous cases probed

All of these were tested and confirmed working:

  • Interface with property hooks → promoted property in implementing class (original bug)
  • Parent class → promoted property in child class
  • Abstract class → promoted property in concrete class
  • Multi-level inheritance (grandparent → parent → promoted child)
  • Generic interfaces with template types (e.g. Container<string>)
  • Constructor @param correctly overrides inherited type
  • Explicit @var on promoted property correctly overrides inherited type

Test

  • NSRT test (tests/PHPStan/Analyser/nsrt/bug-14564.php): Verifies inferred types for promoted properties across all analogous cases — interface, parent class, multi-level inheritance, generics, @param override, and @var override.
  • Rule test (tests/PHPStan/Rules/Properties/OverridingPropertyRuleTest.php::testBug14564): Verifies no false positive covariance errors from OverridingPropertyRule for promoted properties inheriting PHPDoc types from interfaces and parent classes.

Fixes phpstan/phpstan#14564

…structor 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.
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.

Merge the nsrt and RuleTest fixture file into a single one

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

Done. Merged the two test fixture files into a single tests/PHPStan/Analyser/nsrt/bug-14564.php that serves both the NSRT type-inference tests and the OverridingPropertyRuleTest. The separate tests/PHPStan/Rules/Properties/data/bug-14564.php was deleted, and the rule test now points to the shared NSRT file. Both tests pass and make phpstan reports no errors.

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.

We don't benefit of all the resolution

$isInternal = $resolvedPhpDoc->isInternal();
			$isReadOnlyByPhpDoc = $isReadOnlyByPhpDoc || $resolvedPhpDoc->isReadOnly();
			$isFinal = $isFinal || $resolvedPhpDoc->isFinal();
			$isAllowedPrivateMutation = $resolvedPhpDoc->isAllowedPrivateMutation();

We might just do $resolvedPhpDoc = $this->phpDocInheritanceResolver->resolvePhpDocForProperty before the if ($resolvedPhpDoc !== null) { condition.

Also, we'll need a test with a class Bar extending Foo where Foo has a private property $foo and Bar has a public property $foo to ensure the definition does not conflict now we resolve by inheritance

…PhpDocForProperty

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 <noreply@anthropic.com>
@phpstan-bot
Copy link
Copy Markdown
Collaborator Author

Done. Here's what I changed to address VincentLanglet's review:

  1. Moved resolvePhpDocForProperty before the metadata block: Instead of a separate inheritance block that only extracted $phpDocType, the call now happens right before if ($resolvedPhpDoc !== null), so promoted properties also inherit isInternal, isReadOnlyByPhpDoc, isFinal, and isAllowedPrivateMutation from parent/interface properties.

  2. Adjusted @param override logic: Changed the condition from if ($phpDocType === null) to if ($constructorName !== null && ($phpDocType === null || $docComment === null)) — this ensures @param can override inherited types (when there's no local @var) but still respects an explicit @var annotation on the promoted property.

  3. Added private property non-conflict test: New test case with FooWithPrivate (private $items) and BarWithPromoted (public promoted $items) verifying that the child's property doesn't incorrectly inherit from the parent's private property (the existing resolvePropertyPhpDocFromParentClass already returns null for private properties).

@VincentLanglet VincentLanglet deleted the create-pull-request/patch-0y9k9yw branch May 3, 2026 10:56
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