From b2827410b7a1d6c3741b62b0c21d71ef617e8fa2 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/4] 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 8eca891b7f04675e6234c256a20d4fe32cc4c029 Mon Sep 17 00:00:00 2001 From: phpstan-bot Date: Fri, 10 Apr 2026 07:41:14 +0000 Subject: [PATCH 2/4] 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 2da3c78db2ca5b04fce0b8863072d5e24433635d Mon Sep 17 00:00:00 2001 From: phpstan-bot Date: Fri, 10 Apr 2026 08:00:40 +0000 Subject: [PATCH 3/4] 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, From 98d9f063cce1355de42027889c8ded006f47a2a4 Mon Sep 17 00:00:00 2001 From: phpstan-bot Date: Fri, 10 Apr 2026 11:57:17 +0000 Subject: [PATCH 4/4] Move readonly checks into hasRestrictedWriteAccess method Co-Authored-By: Claude Opus 4.6 --- src/Rules/Generics/PropertyVarianceRule.php | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/src/Rules/Generics/PropertyVarianceRule.php b/src/Rules/Generics/PropertyVarianceRule.php index 24272e187eb..e7d9252c1b7 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->hasRestrictedWriteAccess($node) + $variance = $this->hasRestrictedWriteAccess($node) ? TemplateTypeVariance::createCovariant() : TemplateTypeVariance::createInvariant(); @@ -58,6 +58,10 @@ public function processNode(Node $node, Scope $scope): array private function hasRestrictedWriteAccess(ClassPropertyNode $node): bool { + if ($node->isReadOnly() || $node->isReadOnlyByPhpDoc()) { + return true; + } + if ($node->isPrivateSet()) { return true; }