From 46927a839bff076335b7d3177d944d6b7aa547d5 Mon Sep 17 00:00:00 2001 From: VincentLanglet <9052536+VincentLanglet@users.noreply.github.com> Date: Mon, 6 Apr 2026 18:56:46 +0000 Subject: [PATCH 1/3] Fix phpstan/phpstan#12964: Support covariant templates in property hooks and asymmetric visibility - Treat properties with only a get hook (no set hook) as covariant position - Treat properties with private(set) or protected(set) as covariant position - New regression test in tests/PHPStan/Rules/Generics/data/bug-12964.php --- src/Rules/Generics/PropertyVarianceRule.php | 22 ++- .../Generics/PropertyVarianceRuleTest.php | 59 ++++++++ .../PHPStan/Rules/Generics/data/bug-12964.php | 137 ++++++++++++++++++ 3 files changed, 217 insertions(+), 1 deletion(-) create mode 100644 tests/PHPStan/Rules/Generics/data/bug-12964.php diff --git a/src/Rules/Generics/PropertyVarianceRule.php b/src/Rules/Generics/PropertyVarianceRule.php index 03121823d39..f2a71a74ad9 100644 --- a/src/Rules/Generics/PropertyVarianceRule.php +++ b/src/Rules/Generics/PropertyVarianceRule.php @@ -45,7 +45,7 @@ public function processNode(Node $node, Scope $scope): array return []; } - $variance = $node->isReadOnly() || $node->isReadOnlyByPhpDoc() + $variance = $node->isReadOnly() || $node->isReadOnlyByPhpDoc() || $this->isEffectivelyReadOnly($node) ? TemplateTypeVariance::createCovariant() : TemplateTypeVariance::createInvariant(); @@ -56,4 +56,24 @@ public function processNode(Node $node, Scope $scope): array ); } + private function isEffectivelyReadOnly(ClassPropertyNode $node): bool + { + if ($node->isPrivateSet() || $node->isProtectedSet()) { + return true; + } + + $hooks = $node->getHooks(); + if ($hooks === []) { + return false; + } + + foreach ($hooks as $hook) { + if ($hook->name->name === 'set') { + return false; + } + } + + return true; + } + } diff --git a/tests/PHPStan/Rules/Generics/PropertyVarianceRuleTest.php b/tests/PHPStan/Rules/Generics/PropertyVarianceRuleTest.php index b5aeefa8e96..ac5b313a137 100644 --- a/tests/PHPStan/Rules/Generics/PropertyVarianceRuleTest.php +++ b/tests/PHPStan/Rules/Generics/PropertyVarianceRuleTest.php @@ -137,4 +137,63 @@ public function testBug13049(): void $this->analyse([__DIR__ . '/data/bug-13049.php'], []); } + #[RequiresPhp('>= 8.4')] + public function testBug12964(): void + { + $this->analyse([__DIR__ . '/data/bug-12964.php'], [ + [ + 'Template type X is declared as covariant, but occurs in contravariant position in property Bug12964\C::$b.', + 51, + ], + [ + 'Template type X is declared as covariant, but occurs in invariant position in property Bug12964\C::$d.', + 57, + ], + [ + 'Template type X is declared as contravariant, but occurs in covariant position in property Bug12964\D::$a.', + 65, + ], + [ + 'Template type X is declared as contravariant, but occurs in covariant position in property Bug12964\D::$c.', + 71, + ], + [ + 'Template type X is declared as contravariant, but occurs in invariant position in property Bug12964\D::$d.', + 74, + ], + [ + 'Template type X is declared as covariant, but occurs in contravariant position in property Bug12964\E::$b.', + 85, + ], + [ + 'Template type X is declared as covariant, but occurs in invariant position in property Bug12964\E::$d.', + 91, + ], + [ + 'Template type X is declared as covariant, but occurs in contravariant position in property Bug12964\F::$b.', + 103, + ], + [ + 'Template type X is declared as covariant, but occurs in invariant position in property Bug12964\F::$d.', + 109, + ], + [ + 'Template type X is declared as contravariant, but occurs in covariant position in property Bug12964\G::$a.', + 118, + ], + [ + 'Template type X is declared as contravariant, but occurs in covariant position in property Bug12964\G::$c.', + 124, + ], + [ + 'Template type X is declared as contravariant, but occurs in invariant position in property Bug12964\G::$d.', + 127, + ], + [ + 'Template type X is declared as covariant, but occurs in invariant position in property Bug12964\H::$a.', + 136, + ], + ]); + } + } diff --git a/tests/PHPStan/Rules/Generics/data/bug-12964.php b/tests/PHPStan/Rules/Generics/data/bug-12964.php new file mode 100644 index 00000000000..74c507b75a5 --- /dev/null +++ b/tests/PHPStan/Rules/Generics/data/bug-12964.php @@ -0,0 +1,137 @@ += 8.4 + +declare(strict_types = 1); + +namespace Bug12964; + +/** @template-contravariant T */ +interface In { +} + +/** @template-covariant T */ +interface Out { +} + +/** @template T */ +interface Invariant { +} + +/** + * @template-covariant T + */ +interface A +{ + /** + * @var T + */ + public mixed $b { get; } +} + +/** + * @template-covariant T + */ +final class B +{ + /** + * @param T $data + */ + public function __construct( + public private(set) mixed $data, + ) {} +} + +/** + * @template-covariant X + */ +class C { + /** @var X */ + public private(set) mixed $a; + + /** @var In */ + public private(set) mixed $b; + + /** @var Out */ + public private(set) mixed $c; + + /** @var Invariant */ + public private(set) mixed $d; +} + +/** + * @template-contravariant X + */ +class D { + /** @var X */ + public private(set) mixed $a; + + /** @var In */ + public private(set) mixed $b; + + /** @var Out */ + public private(set) mixed $c; + + /** @var Invariant */ + public private(set) mixed $d; +} + +/** + * @template-covariant X + */ +class E { + /** @var X */ + public protected(set) mixed $a; + + /** @var In */ + public protected(set) mixed $b; + + /** @var Out */ + public protected(set) mixed $c; + + /** @var Invariant */ + public protected(set) mixed $d; +} + +/** + * @template-covariant X + */ +interface F +{ + /** @var X */ + public mixed $a { get; } + + /** @var In */ + public mixed $b { get; } + + /** @var Out */ + public mixed $c { get; } + + /** @var Invariant */ + public mixed $d { get; } +} + +/** + * @template-contravariant X + */ +interface G +{ + /** @var X */ + public mixed $a { get; } + + /** @var In */ + public mixed $b { get; } + + /** @var Out */ + public mixed $c { get; } + + /** @var Invariant */ + public mixed $d { get; } +} + +/** + * @template-covariant X + */ +interface H +{ + /** @var X */ + public mixed $a { get; set; } +} From ab3256e6d42054b987736ec0d0dc4b88c417c551 Mon Sep 17 00:00:00 2001 From: phpstan-bot Date: Fri, 10 Apr 2026 07:41:14 +0000 Subject: [PATCH 2/3] Rename isEffectivelyReadOnly to hasRestrictedWriteAccess for clarity Address review feedback: the previous name was unclear about what it checked. The new name better communicates that the method detects properties with restricted write access (asymmetric visibility or get-only hooks). Co-Authored-By: Claude Opus 4.6 --- src/Rules/Generics/PropertyVarianceRule.php | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/Rules/Generics/PropertyVarianceRule.php b/src/Rules/Generics/PropertyVarianceRule.php index f2a71a74ad9..8f7164fa16d 100644 --- a/src/Rules/Generics/PropertyVarianceRule.php +++ b/src/Rules/Generics/PropertyVarianceRule.php @@ -45,7 +45,7 @@ public function processNode(Node $node, Scope $scope): array return []; } - $variance = $node->isReadOnly() || $node->isReadOnlyByPhpDoc() || $this->isEffectivelyReadOnly($node) + $variance = $node->isReadOnly() || $node->isReadOnlyByPhpDoc() || $this->hasRestrictedWriteAccess($node) ? TemplateTypeVariance::createCovariant() : TemplateTypeVariance::createInvariant(); @@ -56,7 +56,7 @@ public function processNode(Node $node, Scope $scope): array ); } - private function isEffectivelyReadOnly(ClassPropertyNode $node): bool + private function hasRestrictedWriteAccess(ClassPropertyNode $node): bool { if ($node->isPrivateSet() || $node->isProtectedSet()) { return true; From 23a0cf77ba8a3bf3453b31f8c35adf6baacb8f43 Mon Sep 17 00:00:00 2001 From: phpstan-bot Date: Fri, 10 Apr 2026 08:00:40 +0000 Subject: [PATCH 3/3] Treat protected(set) properties as invariant for template variance protected(set) allows subclasses to write to the property, just like regular protected properties. Only private(set) truly restricts writes to the declaring class, so only private(set) should be treated as covariant. Co-Authored-By: Claude Opus 4.6 --- src/Rules/Generics/PropertyVarianceRule.php | 2 +- .../Rules/Generics/PropertyVarianceRuleTest.php | 10 +++++++++- 2 files changed, 10 insertions(+), 2 deletions(-) diff --git a/src/Rules/Generics/PropertyVarianceRule.php b/src/Rules/Generics/PropertyVarianceRule.php index 8f7164fa16d..24272e187eb 100644 --- a/src/Rules/Generics/PropertyVarianceRule.php +++ b/src/Rules/Generics/PropertyVarianceRule.php @@ -58,7 +58,7 @@ public function processNode(Node $node, Scope $scope): array private function hasRestrictedWriteAccess(ClassPropertyNode $node): bool { - if ($node->isPrivateSet() || $node->isProtectedSet()) { + if ($node->isPrivateSet()) { return true; } diff --git a/tests/PHPStan/Rules/Generics/PropertyVarianceRuleTest.php b/tests/PHPStan/Rules/Generics/PropertyVarianceRuleTest.php index ac5b313a137..bec80472536 100644 --- a/tests/PHPStan/Rules/Generics/PropertyVarianceRuleTest.php +++ b/tests/PHPStan/Rules/Generics/PropertyVarianceRuleTest.php @@ -162,9 +162,17 @@ public function testBug12964(): void 74, ], [ - 'Template type X is declared as covariant, but occurs in contravariant position in property Bug12964\E::$b.', + 'Template type X is declared as covariant, but occurs in invariant position in property Bug12964\E::$a.', + 82, + ], + [ + 'Template type X is declared as covariant, but occurs in invariant position in property Bug12964\E::$b.', 85, ], + [ + 'Template type X is declared as covariant, but occurs in invariant position in property Bug12964\E::$c.', + 88, + ], [ 'Template type X is declared as covariant, but occurs in invariant position in property Bug12964\E::$d.', 91,