From b6e9f5d38cddd23ebdbf06d7e4298456dfd10f69 Mon Sep 17 00:00:00 2001 From: ILJA MANYUTIN Date: Sat, 11 Jul 2026 00:47:34 +0300 Subject: [PATCH 1/4] [Php80] Skip promotion when property type is wider than constructor param on ClassPropertyAssignToConstructorPromotionRector (#9809) --- .../Fixture/narrower_property_type.php.inc | 28 ++++++++++++++++ .../Fixture/skip_wider_property_type.php.inc | 18 +++++++++++ ...ertyAssignToConstructorPromotionRector.php | 32 +++++++++++++++++++ 3 files changed, 78 insertions(+) create mode 100644 rules-tests/Php80/Rector/Class_/ClassPropertyAssignToConstructorPromotionRector/Fixture/narrower_property_type.php.inc create mode 100644 rules-tests/Php80/Rector/Class_/ClassPropertyAssignToConstructorPromotionRector/Fixture/skip_wider_property_type.php.inc diff --git a/rules-tests/Php80/Rector/Class_/ClassPropertyAssignToConstructorPromotionRector/Fixture/narrower_property_type.php.inc b/rules-tests/Php80/Rector/Class_/ClassPropertyAssignToConstructorPromotionRector/Fixture/narrower_property_type.php.inc new file mode 100644 index 00000000000..570e1a2f252 --- /dev/null +++ b/rules-tests/Php80/Rector/Class_/ClassPropertyAssignToConstructorPromotionRector/Fixture/narrower_property_type.php.inc @@ -0,0 +1,28 @@ +entity = $entity; + } +} + +?> +----- + diff --git a/rules-tests/Php80/Rector/Class_/ClassPropertyAssignToConstructorPromotionRector/Fixture/skip_wider_property_type.php.inc b/rules-tests/Php80/Rector/Class_/ClassPropertyAssignToConstructorPromotionRector/Fixture/skip_wider_property_type.php.inc new file mode 100644 index 00000000000..578c12e6f96 --- /dev/null +++ b/rules-tests/Php80/Rector/Class_/ClassPropertyAssignToConstructorPromotionRector/Fixture/skip_wider_property_type.php.inc @@ -0,0 +1,18 @@ +entity = $entity; + } + + public function clear(): void + { + $this->entity = null; + } +} diff --git a/rules/Php80/Rector/Class_/ClassPropertyAssignToConstructorPromotionRector.php b/rules/Php80/Rector/Class_/ClassPropertyAssignToConstructorPromotionRector.php index 7e26ea9b871..722db4da4be 100644 --- a/rules/Php80/Rector/Class_/ClassPropertyAssignToConstructorPromotionRector.php +++ b/rules/Php80/Rector/Class_/ClassPropertyAssignToConstructorPromotionRector.php @@ -220,6 +220,10 @@ public function refactor(Node $node): ?Node continue; } + if ($this->shouldSkipWiderPropertyType($property, $param)) { + continue; + } + $hasChanged = true; // remove property from class @@ -479,4 +483,32 @@ private function shouldUsePropertyTypeForPromotedParam(Property $property, Param return $this->typeComparator->areTypesEqual($type, $paramTypeWithoutNull); } + + /** + * Promotion shares one type. When processUnionType would copy the property type onto the + * param and that type is strictly wider (e.g. ?object vs object), skip — otherwise the + * constructor contract is loosened. Narrowing (object vs ?object) stays allowed. + * Interface vs implementation does not fire here: shouldUsePropertyTypeForPromotedParam + * is false when the non-null base types differ. + */ + private function shouldSkipWiderPropertyType(Property $property, Param $param): bool + { + if (! $this->shouldUsePropertyTypeForPromotedParam($property, $param)) { + return false; + } + + if (! $property->type instanceof Node || ! $param->type instanceof Node) { + return false; + } + + $propertyType = $this->staticTypeMapper->mapPhpParserNodePHPStanType($property->type); + $paramType = $this->staticTypeMapper->mapPhpParserNodePHPStanType($param->type); + + if ($param->default instanceof Expr) { + $paramType = TypeCombinator::union($paramType, $this->getType($param->default)); + } + + return $this->typeComparator->isSubtype($paramType, $propertyType) + && ! $this->typeComparator->areTypesEqual($propertyType, $paramType); + } } From 328062cdd2e5087e97aca612cf5cef867a7f81fe Mon Sep 17 00:00:00 2001 From: ILJA MANYUTIN Date: Sat, 11 Jul 2026 22:53:46 +0300 Subject: [PATCH 2/4] [Php80] Skip promotion only when property is assigned null on ClassPropertyAssignToConstructorPromotionRector(#9809) --- .../Fixture/skip_wider_property_type.php.inc | 2 +- ..._property_type_without_null_assign.php.inc | 30 +++++++++++++ ...ertyAssignToConstructorPromotionRector.php | 45 ++++++++++--------- 3 files changed, 54 insertions(+), 23 deletions(-) create mode 100644 rules-tests/Php80/Rector/Class_/ClassPropertyAssignToConstructorPromotionRector/Fixture/wider_property_type_without_null_assign.php.inc diff --git a/rules-tests/Php80/Rector/Class_/ClassPropertyAssignToConstructorPromotionRector/Fixture/skip_wider_property_type.php.inc b/rules-tests/Php80/Rector/Class_/ClassPropertyAssignToConstructorPromotionRector/Fixture/skip_wider_property_type.php.inc index 578c12e6f96..086a63a54ea 100644 --- a/rules-tests/Php80/Rector/Class_/ClassPropertyAssignToConstructorPromotionRector/Fixture/skip_wider_property_type.php.inc +++ b/rules-tests/Php80/Rector/Class_/ClassPropertyAssignToConstructorPromotionRector/Fixture/skip_wider_property_type.php.inc @@ -4,7 +4,7 @@ namespace Rector\Tests\Php80\Rector\Class_\ClassPropertyAssignToConstructorPromo final class SkipWiderPropertyType { - public private(set) ?object $entity = null; + private(set) ?object $entity; public function __construct(object $entity) { diff --git a/rules-tests/Php80/Rector/Class_/ClassPropertyAssignToConstructorPromotionRector/Fixture/wider_property_type_without_null_assign.php.inc b/rules-tests/Php80/Rector/Class_/ClassPropertyAssignToConstructorPromotionRector/Fixture/wider_property_type_without_null_assign.php.inc new file mode 100644 index 00000000000..3fb66d9e591 --- /dev/null +++ b/rules-tests/Php80/Rector/Class_/ClassPropertyAssignToConstructorPromotionRector/Fixture/wider_property_type_without_null_assign.php.inc @@ -0,0 +1,30 @@ +entity = $entity; + } + +} + +?> +----- + diff --git a/rules/Php80/Rector/Class_/ClassPropertyAssignToConstructorPromotionRector.php b/rules/Php80/Rector/Class_/ClassPropertyAssignToConstructorPromotionRector.php index 722db4da4be..41d9e817e9c 100644 --- a/rules/Php80/Rector/Class_/ClassPropertyAssignToConstructorPromotionRector.php +++ b/rules/Php80/Rector/Class_/ClassPropertyAssignToConstructorPromotionRector.php @@ -6,6 +6,7 @@ use PhpParser\Node; use PhpParser\Node\Expr; +use PhpParser\Node\Expr\Assign; use PhpParser\Node\Expr\PropertyFetch; use PhpParser\Node\Expr\Variable; use PhpParser\Node\FunctionLike; @@ -35,6 +36,7 @@ use Rector\Php80\DocBlock\PropertyPromotionDocBlockMerger; use Rector\Php80\Guard\MakePropertyPromotionGuard; use Rector\Php80\NodeAnalyzer\PromotedPropertyCandidateResolver; +use Rector\PhpParser\Node\BetterNodeFinder; use Rector\PhpParser\Node\Value\ValueResolver; use Rector\PHPStanStaticTypeMapper\Enum\TypeKind; use Rector\Rector\AbstractRector; @@ -98,6 +100,7 @@ public function __construct( private readonly PhpDocInfoFactory $phpDocInfoFactory, private readonly StaticTypeMapper $staticTypeMapper, private readonly ValueResolver $valueResolver, + private readonly BetterNodeFinder $betterNodeFinder, ) { } @@ -220,7 +223,7 @@ public function refactor(Node $node): ?Node continue; } - if ($this->shouldSkipWiderPropertyType($property, $param)) { + if ($this->shouldSkipPropertyAssignedNull($node, $propertyName)) { continue; } @@ -484,31 +487,29 @@ private function shouldUsePropertyTypeForPromotedParam(Property $property, Param return $this->typeComparator->areTypesEqual($type, $paramTypeWithoutNull); } - /** - * Promotion shares one type. When processUnionType would copy the property type onto the - * param and that type is strictly wider (e.g. ?object vs object), skip — otherwise the - * constructor contract is loosened. Narrowing (object vs ?object) stays allowed. - * Interface vs implementation does not fire here: shouldUsePropertyTypeForPromotedParam - * is false when the non-null base types differ. - */ - private function shouldSkipWiderPropertyType(Property $property, Param $param): bool + private function shouldSkipPropertyAssignedNull(Class_ $class, string $propertyName): bool { - if (! $this->shouldUsePropertyTypeForPromotedParam($property, $param)) { - return false; - } + return (bool) $this->betterNodeFinder->findFirst( + $class, + function (Node $node) use ($propertyName): bool { + if (! $node instanceof Assign) { + return false; + } - if (! $property->type instanceof Node || ! $param->type instanceof Node) { - return false; - } + if (! $node->var instanceof PropertyFetch) { + return false; + } - $propertyType = $this->staticTypeMapper->mapPhpParserNodePHPStanType($property->type); - $paramType = $this->staticTypeMapper->mapPhpParserNodePHPStanType($param->type); + if (! $this->isName($node->var->var, 'this')) { + return false; + } - if ($param->default instanceof Expr) { - $paramType = TypeCombinator::union($paramType, $this->getType($param->default)); - } + if (! $this->isName($node->var->name, $propertyName)) { + return false; + } - return $this->typeComparator->isSubtype($paramType, $propertyType) - && ! $this->typeComparator->areTypesEqual($propertyType, $paramType); + return $this->valueResolver->isNull($node->expr); + } + ); } } From d8bfe6ba7ae4f7736cedcb55068a0ee9c6e852ab Mon Sep 17 00:00:00 2001 From: ximki-vinki <104347447+ximki-vinki@users.noreply.github.com> Date: Sun, 12 Jul 2026 20:30:54 +0300 Subject: [PATCH 3/4] Update rules-tests/Php80/Rector/Class_/ClassPropertyAssignToConstructorPromotionRector/Fixture/wider_property_type_without_null_assign.php.inc Co-authored-by: Tomas Votruba --- .../Fixture/wider_property_type_without_null_assign.php.inc | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/rules-tests/Php80/Rector/Class_/ClassPropertyAssignToConstructorPromotionRector/Fixture/wider_property_type_without_null_assign.php.inc b/rules-tests/Php80/Rector/Class_/ClassPropertyAssignToConstructorPromotionRector/Fixture/wider_property_type_without_null_assign.php.inc index 3fb66d9e591..35672d1c57b 100644 --- a/rules-tests/Php80/Rector/Class_/ClassPropertyAssignToConstructorPromotionRector/Fixture/wider_property_type_without_null_assign.php.inc +++ b/rules-tests/Php80/Rector/Class_/ClassPropertyAssignToConstructorPromotionRector/Fixture/wider_property_type_without_null_assign.php.inc @@ -21,7 +21,7 @@ namespace Rector\Tests\Php80\Rector\Class_\ClassPropertyAssignToConstructorPromo final class WiderPropertyTypeWithoutNullAssign { - public function __construct(private(set) ?object $entity) + public function __construct(private(set) object $entity) { } From 7b0b7a0d24daccccb7b2c22295850dee20575f1c Mon Sep 17 00:00:00 2001 From: ILJA MANYUTIN Date: Sun, 12 Jul 2026 22:39:37 +0300 Subject: [PATCH 4/4] [Php80] Do not widen promoted param with nullable property type on ClassPropertyAssignToConstructorPromotionRector (#9809) --- ...PropertyAssignToConstructorPromotionRector.php | 15 ++++++++++++++- 1 file changed, 14 insertions(+), 1 deletion(-) diff --git a/rules/Php80/Rector/Class_/ClassPropertyAssignToConstructorPromotionRector.php b/rules/Php80/Rector/Class_/ClassPropertyAssignToConstructorPromotionRector.php index 41d9e817e9c..079948aaaae 100644 --- a/rules/Php80/Rector/Class_/ClassPropertyAssignToConstructorPromotionRector.php +++ b/rules/Php80/Rector/Class_/ClassPropertyAssignToConstructorPromotionRector.php @@ -484,7 +484,20 @@ private function shouldUsePropertyTypeForPromotedParam(Property $property, Param $paramType = $this->staticTypeMapper->mapPhpParserNodePHPStanType($param->type); $paramTypeWithoutNull = TypeCombinator::removeNull($paramType); - return $this->typeComparator->areTypesEqual($type, $paramTypeWithoutNull); + if (! $this->typeComparator->areTypesEqual($type, $paramTypeWithoutNull)) { + return false; + } + + if ($param->default instanceof Expr) { + $paramType = TypeCombinator::union($paramType, $this->getType($param->default)); + } + + if ($this->typeComparator->isSubtype($paramType, $propertyType) + && ! $this->typeComparator->areTypesEqual($propertyType, $paramType)) { + return false; + } + + return true; } private function shouldSkipPropertyAssignedNull(Class_ $class, string $propertyName): bool