Skip to content

Commit d78d9c0

Browse files
Fix phpstan/phpstan#12871: Allow readonly property init in child classes on PHP 8.4+ (#5513)
1 parent d7e37ec commit d78d9c0

3 files changed

Lines changed: 102 additions & 14 deletions

File tree

src/Rules/Properties/ReadOnlyPropertyAssignRule.php

Lines changed: 12 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99
use PHPStan\Node\Expr\SetOffsetValueTypeExpr;
1010
use PHPStan\Node\Expr\UnsetOffsetExpr;
1111
use PHPStan\Node\PropertyAssignNode;
12+
use PHPStan\Php\PhpVersion;
1213
use PHPStan\Reflection\ConstructorsHelper;
1314
use PHPStan\Reflection\MethodReflection;
1415
use PHPStan\Rules\Rule;
@@ -30,6 +31,7 @@ final class ReadOnlyPropertyAssignRule implements Rule
3031
public function __construct(
3132
private PropertyReflectionFinder $propertyReflectionFinder,
3233
private ConstructorsHelper $constructorsHelper,
34+
private PhpVersion $phpVersion,
3335
)
3436
{
3537
}
@@ -77,11 +79,16 @@ public function processNode(Node $node, Scope $scope): array
7779

7880
$scopeClassReflection = $scope->getClassReflection();
7981
if ($scopeClassReflection->getName() !== $declaringClass->getName()) {
80-
$errors[] = RuleErrorBuilder::message(sprintf('Readonly property %s::$%s is assigned outside of its declaring class.', $declaringClass->getDisplayName(), $propertyReflection->getName()))
81-
->line($propertyFetch->name->getStartLine())
82-
->identifier('property.readOnlyAssignOutOfClass')
83-
->build();
84-
continue;
82+
$allowedInSubclass = $this->phpVersion->supportsAsymmetricVisibility()
83+
&& !$propertyReflection->isPrivateSet()
84+
&& $scopeClassReflection->isSubclassOfClass($propertyReflection->getDeclaringClass());
85+
if (!$allowedInSubclass) {
86+
$errors[] = RuleErrorBuilder::message(sprintf('Readonly property %s::$%s is assigned outside of its declaring class.', $declaringClass->getDisplayName(), $propertyReflection->getName()))
87+
->line($propertyFetch->name->getStartLine())
88+
->identifier('property.readOnlyAssignOutOfClass')
89+
->build();
90+
continue;
91+
}
8592
}
8693

8794
$scopeMethod = $scope->getFunction();

tests/PHPStan/Rules/Properties/ReadOnlyPropertyAssignRuleTest.php

Lines changed: 33 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22

33
namespace PHPStan\Rules\Properties;
44

5+
use PHPStan\Php\PhpVersion;
56
use PHPStan\Reflection\ConstructorsHelper;
67
use PHPStan\Rules\Rule;
78
use PHPStan\Testing\RuleTestCase;
@@ -25,6 +26,7 @@ protected function getRule(): Rule
2526
'ReadonlyPropertyAssign\\TestCase::setUp',
2627
],
2728
),
29+
new PhpVersion(PHP_VERSION_ID),
2830
);
2931
}
3032

@@ -36,26 +38,35 @@ public function testRule(): void
3638
'Readonly property ReadonlyPropertyAssign\Foo::$foo is assigned outside of the constructor.',
3739
21,
3840
],
39-
[
41+
];
42+
43+
if (PHP_VERSION_ID < 80400) {
44+
// Since PHP 8.4, readonly is implicitly protected(set),
45+
// so child classes may initialize the property.
46+
$errors[] = [
4047
'Readonly property ReadonlyPropertyAssign\Foo::$bar is assigned outside of its declaring class.',
4148
33,
42-
],
43-
[
49+
];
50+
$errors[] = [
4451
'Readonly property ReadonlyPropertyAssign\Foo::$baz is assigned outside of its declaring class.',
4552
34,
46-
],
47-
[
53+
];
54+
$errors[] = [
4855
'Readonly property ReadonlyPropertyAssign\Foo::$bar is assigned outside of its declaring class.',
4956
39,
50-
],
51-
];
52-
53-
if (PHP_VERSION_ID < 80400) {
57+
];
5458
// reported by AccessPropertiesInAssignRule on 8.4+
5559
$errors[] = [
5660
'Readonly property ReadonlyPropertyAssign\Foo::$baz is assigned outside of its declaring class.',
5761
46,
5862
];
63+
} else {
64+
// On PHP 8.4+ the assignment is allowed by visibility rules,
65+
// but still has to happen in a constructor of the child class.
66+
$errors[] = [
67+
'Readonly property ReadonlyPropertyAssign\Foo::$bar is assigned outside of the constructor.',
68+
39,
69+
];
5970
}
6071

6172
$errors = array_merge($errors, [
@@ -180,4 +191,17 @@ public function testCloneWith(): void
180191
$this->analyse([__DIR__ . '/data/readonly-property-assign-clone-with.php'], []);
181192
}
182193

194+
#[RequiresPhp('>= 8.4.0')]
195+
public function testBug12871(): void
196+
{
197+
// The private(set) assignment in a subclass is reported by AccessPropertiesInAssignRule,
198+
// so this rule only reports the write outside of a constructor.
199+
$this->analyse([__DIR__ . '/data/bug-12871.php'], [
200+
[
201+
'Readonly property Bug12871\A::$foo is assigned outside of the constructor.',
202+
54,
203+
],
204+
]);
205+
}
206+
183207
}
Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
<?php // lint >= 8.4
2+
3+
namespace Bug12871;
4+
5+
abstract readonly class A
6+
{
7+
8+
protected string $foo;
9+
10+
public function __construct()
11+
{
12+
$this->foo = '';
13+
}
14+
15+
}
16+
17+
readonly class B extends A
18+
{
19+
20+
public function __construct()
21+
{
22+
$this->foo = 'foo';
23+
}
24+
25+
}
26+
27+
readonly class PrivateSetParent
28+
{
29+
30+
public private(set) string $bar;
31+
32+
public function __construct()
33+
{
34+
$this->bar = '';
35+
}
36+
37+
}
38+
39+
readonly class PrivateSetChild extends PrivateSetParent
40+
{
41+
42+
public function __construct()
43+
{
44+
$this->bar = 'nope'; // report - private(set)
45+
}
46+
47+
}
48+
49+
readonly class NonConstructorChild extends A
50+
{
51+
52+
public function init(): void
53+
{
54+
$this->foo = 'nope'; // report - outside constructor
55+
}
56+
57+
}

0 commit comments

Comments
 (0)