diff --git a/src/Analyser/MutatingScope.php b/src/Analyser/MutatingScope.php index ddd6bd0211b..122340cc27b 100644 --- a/src/Analyser/MutatingScope.php +++ b/src/Analyser/MutatingScope.php @@ -1068,15 +1068,18 @@ public function issetCheck(Expr $expr, callable $typeCallback, ?bool $result = n if ($propertyReflection->hasNativeType() && !$propertyReflection->isVirtual()->yes()) { if (!$this->hasExpressionType($expr)->yes()) { - if ($expr instanceof Node\Expr\PropertyFetch) { - return $this->issetCheckUndefined($expr->var); - } + $nativeReflection = $propertyReflection->getNativeReflection(); + if ($nativeReflection === null || !$nativeReflection->isPromoted() || (!$nativeReflection->isReadOnly() && !$nativeReflection->isHooked())) { + if ($expr instanceof Node\Expr\PropertyFetch) { + return $this->issetCheckUndefined($expr->var); + } - if ($expr->class instanceof Expr) { - return $this->issetCheckUndefined($expr->class); - } + if ($expr->class instanceof Expr) { + return $this->issetCheckUndefined($expr->class); + } - return null; + return null; + } } } diff --git a/src/Rules/IssetCheck.php b/src/Rules/IssetCheck.php index f8f7767d7aa..594d01dd259 100644 --- a/src/Rules/IssetCheck.php +++ b/src/Rules/IssetCheck.php @@ -183,7 +183,11 @@ static function (Type $type) use ($typeMessageCallback): ?string { if (!$scope->hasExpressionType($expr)->yes()) { $nativeReflection = $propertyReflection->getNativeReflection(); - if ($nativeReflection !== null && !$nativeReflection->getNativeReflection()->hasDefaultValue()) { + if ( + $nativeReflection !== null + && !$nativeReflection->getNativeReflection()->hasDefaultValue() + && (!$nativeReflection->isPromoted() || (!$nativeReflection->isReadOnly() && !$nativeReflection->isHooked())) + ) { return null; } } diff --git a/tests/PHPStan/Rules/Variables/NullCoalesceRuleTest.php b/tests/PHPStan/Rules/Variables/NullCoalesceRuleTest.php index 88af910f2b5..9abe4cf394c 100644 --- a/tests/PHPStan/Rules/Variables/NullCoalesceRuleTest.php +++ b/tests/PHPStan/Rules/Variables/NullCoalesceRuleTest.php @@ -392,6 +392,28 @@ public function testBug14458(): void $this->analyse([__DIR__ . '/data/bug-14458.php'], []); } + #[RequiresPhp('>= 8.1')] + public function testBug14459(): void + { + $this->analyse([__DIR__ . '/data/bug-14459.php'], [ + [ + 'Property Bug14459\Dto::$policyholderId (stdClass) on left side of ?? is not nullable.', + 34, + ], + ]); + } + + #[RequiresPhp('>= 8.4')] + public function testBug14459Hooked(): void + { + $this->analyse([__DIR__ . '/data/bug-14459-hooked.php'], [ + [ + 'Property Bug14459Hooked\DtoHooked::$policyholderId (stdClass) on left side of ?? is not nullable.', + 21, + ], + ]); + } + public function testBug14393(): void { $this->analyse([__DIR__ . '/data/bug-14393.php'], [ diff --git a/tests/PHPStan/Rules/Variables/data/bug-14459-hooked.php b/tests/PHPStan/Rules/Variables/data/bug-14459-hooked.php new file mode 100644 index 00000000000..48cc0cdf525 --- /dev/null +++ b/tests/PHPStan/Rules/Variables/data/bug-14459-hooked.php @@ -0,0 +1,29 @@ += 8.4 + +declare(strict_types = 1); + +namespace Bug14459Hooked; + +final class DtoHooked +{ + public function __construct( + public \stdClass $policyholderId { + set => $value; + }, + public ?\stdClass $nullablePolicyholderId { + set => $value; + }, + ) {} +} + +function testHooked(DtoHooked $dto): \stdClass +{ + $x = $dto->policyholderId ?? new \stdClass(); + return $x; +} + +function testHookedNullable(DtoHooked $dto): \stdClass +{ + $x = $dto->nullablePolicyholderId ?? new \stdClass(); + return $x; +} diff --git a/tests/PHPStan/Rules/Variables/data/bug-14459.php b/tests/PHPStan/Rules/Variables/data/bug-14459.php new file mode 100644 index 00000000000..4276cb901f6 --- /dev/null +++ b/tests/PHPStan/Rules/Variables/data/bug-14459.php @@ -0,0 +1,54 @@ += 8.1 + +declare(strict_types = 1); + +namespace Bug14459; + +final class Dto +{ + public function __construct( + public readonly \stdClass $payerId, + public readonly \stdClass $policyholderId, + public readonly ?\stdClass $nullablePolicyholderId, + ) {} +} + +final class DtoNonReadonly +{ + public function __construct( + public \stdClass $payerId, + ) {} +} + +class DtoNonPromotedReadonly +{ + public readonly \stdClass $payerId; + + public function __construct(\stdClass $payerId) { + $this->payerId = $payerId; + } +} + +function test(Dto $dto): \stdClass +{ + $x = $dto->policyholderId ?? $dto->payerId; + return $x; +} + +function testNullable(Dto $dto): \stdClass +{ + $x = $dto->nullablePolicyholderId ?? $dto->payerId; + return $x; +} + +function testNonReadonly(DtoNonReadonly $dto): \stdClass +{ + $x = $dto->payerId ?? new \stdClass(); + return $x; +} + +function testNonPromotedReadonly(DtoNonPromotedReadonly $dto): \stdClass +{ + $x = $dto->payerId ?? new \stdClass(); + return $x; +}