From 95b0e7f2121cb52dd025ccbc63906d664ab9dd4e Mon Sep 17 00:00:00 2001 From: Nicolai Ehrhardt <245527909+predictor2718@users.noreply.github.com> Date: Wed, 22 Apr 2026 19:04:21 +0200 Subject: [PATCH] Allow readonly property init in child class on PHP 8.4+ --- .../Properties/ReadOnlyPropertyAssignRule.php | 17 ++++-- .../ReadOnlyPropertyAssignRuleTest.php | 42 +++++++++++--- .../Rules/Properties/data/bug-12871.php | 57 +++++++++++++++++++ 3 files changed, 102 insertions(+), 14 deletions(-) create mode 100644 tests/PHPStan/Rules/Properties/data/bug-12871.php diff --git a/src/Rules/Properties/ReadOnlyPropertyAssignRule.php b/src/Rules/Properties/ReadOnlyPropertyAssignRule.php index 987e000256e..bf078a3839a 100644 --- a/src/Rules/Properties/ReadOnlyPropertyAssignRule.php +++ b/src/Rules/Properties/ReadOnlyPropertyAssignRule.php @@ -9,6 +9,7 @@ use PHPStan\Node\Expr\SetOffsetValueTypeExpr; use PHPStan\Node\Expr\UnsetOffsetExpr; use PHPStan\Node\PropertyAssignNode; +use PHPStan\Php\PhpVersion; use PHPStan\Reflection\ConstructorsHelper; use PHPStan\Reflection\MethodReflection; use PHPStan\Rules\Rule; @@ -30,6 +31,7 @@ final class ReadOnlyPropertyAssignRule implements Rule public function __construct( private PropertyReflectionFinder $propertyReflectionFinder, private ConstructorsHelper $constructorsHelper, + private PhpVersion $phpVersion, ) { } @@ -77,11 +79,16 @@ public function processNode(Node $node, Scope $scope): array $scopeClassReflection = $scope->getClassReflection(); if ($scopeClassReflection->getName() !== $declaringClass->getName()) { - $errors[] = RuleErrorBuilder::message(sprintf('Readonly property %s::$%s is assigned outside of its declaring class.', $declaringClass->getDisplayName(), $propertyReflection->getName())) - ->line($propertyFetch->name->getStartLine()) - ->identifier('property.readOnlyAssignOutOfClass') - ->build(); - continue; + $allowedInSubclass = $this->phpVersion->supportsAsymmetricVisibility() + && !$propertyReflection->isPrivateSet() + && $scopeClassReflection->isSubclassOfClass($propertyReflection->getDeclaringClass()); + if (!$allowedInSubclass) { + $errors[] = RuleErrorBuilder::message(sprintf('Readonly property %s::$%s is assigned outside of its declaring class.', $declaringClass->getDisplayName(), $propertyReflection->getName())) + ->line($propertyFetch->name->getStartLine()) + ->identifier('property.readOnlyAssignOutOfClass') + ->build(); + continue; + } } $scopeMethod = $scope->getFunction(); diff --git a/tests/PHPStan/Rules/Properties/ReadOnlyPropertyAssignRuleTest.php b/tests/PHPStan/Rules/Properties/ReadOnlyPropertyAssignRuleTest.php index c2e9153b01e..b12e6365cf5 100644 --- a/tests/PHPStan/Rules/Properties/ReadOnlyPropertyAssignRuleTest.php +++ b/tests/PHPStan/Rules/Properties/ReadOnlyPropertyAssignRuleTest.php @@ -2,6 +2,7 @@ namespace PHPStan\Rules\Properties; +use PHPStan\Php\PhpVersion; use PHPStan\Reflection\ConstructorsHelper; use PHPStan\Rules\Rule; use PHPStan\Testing\RuleTestCase; @@ -25,6 +26,7 @@ protected function getRule(): Rule 'ReadonlyPropertyAssign\\TestCase::setUp', ], ), + new PhpVersion(PHP_VERSION_ID), ); } @@ -36,26 +38,35 @@ public function testRule(): void 'Readonly property ReadonlyPropertyAssign\Foo::$foo is assigned outside of the constructor.', 21, ], - [ + ]; + + if (PHP_VERSION_ID < 80400) { + // Since PHP 8.4, readonly is implicitly protected(set), + // so child classes may initialize the property. + $errors[] = [ 'Readonly property ReadonlyPropertyAssign\Foo::$bar is assigned outside of its declaring class.', 33, - ], - [ + ]; + $errors[] = [ 'Readonly property ReadonlyPropertyAssign\Foo::$baz is assigned outside of its declaring class.', 34, - ], - [ + ]; + $errors[] = [ 'Readonly property ReadonlyPropertyAssign\Foo::$bar is assigned outside of its declaring class.', 39, - ], - ]; - - if (PHP_VERSION_ID < 80400) { + ]; // reported by AccessPropertiesInAssignRule on 8.4+ $errors[] = [ 'Readonly property ReadonlyPropertyAssign\Foo::$baz is assigned outside of its declaring class.', 46, ]; + } else { + // On PHP 8.4+ the assignment is allowed by visibility rules, + // but still has to happen in a constructor of the child class. + $errors[] = [ + 'Readonly property ReadonlyPropertyAssign\Foo::$bar is assigned outside of the constructor.', + 39, + ]; } $errors = array_merge($errors, [ @@ -180,4 +191,17 @@ public function testCloneWith(): void $this->analyse([__DIR__ . '/data/readonly-property-assign-clone-with.php'], []); } + #[RequiresPhp('>= 8.4.0')] + public function testBug12871(): void + { + // The private(set) assignment in a subclass is reported by AccessPropertiesInAssignRule, + // so this rule only reports the write outside of a constructor. + $this->analyse([__DIR__ . '/data/bug-12871.php'], [ + [ + 'Readonly property Bug12871\A::$foo is assigned outside of the constructor.', + 54, + ], + ]); + } + } diff --git a/tests/PHPStan/Rules/Properties/data/bug-12871.php b/tests/PHPStan/Rules/Properties/data/bug-12871.php new file mode 100644 index 00000000000..a5632bdc0a7 --- /dev/null +++ b/tests/PHPStan/Rules/Properties/data/bug-12871.php @@ -0,0 +1,57 @@ += 8.4 + +namespace Bug12871; + +abstract readonly class A +{ + + protected string $foo; + + public function __construct() + { + $this->foo = ''; + } + +} + +readonly class B extends A +{ + + public function __construct() + { + $this->foo = 'foo'; + } + +} + +readonly class PrivateSetParent +{ + + public private(set) string $bar; + + public function __construct() + { + $this->bar = ''; + } + +} + +readonly class PrivateSetChild extends PrivateSetParent +{ + + public function __construct() + { + $this->bar = 'nope'; // report - private(set) + } + +} + +readonly class NonConstructorChild extends A +{ + + public function init(): void + { + $this->foo = 'nope'; // report - outside constructor + } + +}