From 1865530ec32e0514241955b75e0889365c617c81 Mon Sep 17 00:00:00 2001 From: VincentLanglet <9052536+VincentLanglet@users.noreply.github.com> Date: Fri, 3 Apr 2026 22:26:50 +0000 Subject: [PATCH 1/8] Fix ObjectType::equals() treating generic and non-generic types as equal - ObjectType::equals(GenericObjectType) incorrectly returned true when both had the same class name, because it didn't check whether generic type arguments differed - This caused MutatingScope::mergeWith() native type optimization to replace native types (e.g. ArrayObject) with PHPDoc-enriched types (e.g. ArrayObject), contaminating native type tracking after any branch merge - With treatPhpDocTypesAsCertain: false, this led to false "will always evaluate to true" errors for is_string/is_int checks on foreach values from generic objects - Added get_class equality check in ObjectType::equals() which also subsumes the old instanceof EnumCaseObjectType check - Removed now-unneeded baseline entry for the EnumCaseObjectType instanceof check - New regression test in tests/PHPStan/Rules/Comparison/data/bug-14429.php --- phpstan-baseline.neon | 6 --- src/Type/ObjectType.php | 3 +- ...mpossibleCheckTypeFunctionCallRuleTest.php | 7 +++ .../Rules/Comparison/data/bug-14429.php | 43 +++++++++++++++++++ 4 files changed, 52 insertions(+), 7 deletions(-) create mode 100644 tests/PHPStan/Rules/Comparison/data/bug-14429.php diff --git a/phpstan-baseline.neon b/phpstan-baseline.neon index 43a17e853e1..a6edd1ace17 100644 --- a/phpstan-baseline.neon +++ b/phpstan-baseline.neon @@ -1473,12 +1473,6 @@ parameters: count: 1 path: src/Type/ObjectShapeType.php - - - rawMessage: 'Doing instanceof PHPStan\Type\Enum\EnumCaseObjectType is error-prone and deprecated. Use Type::getEnumCases() instead.' - identifier: phpstanApi.instanceofType - count: 1 - path: src/Type/ObjectType.php - - rawMessage: Doing instanceof PHPStan\Type\Generic\GenericObjectType is error-prone and deprecated. identifier: phpstanApi.instanceofType diff --git a/src/Type/ObjectType.php b/src/Type/ObjectType.php index f90a09d6908..9ecbbe44c8d 100644 --- a/src/Type/ObjectType.php +++ b/src/Type/ObjectType.php @@ -56,6 +56,7 @@ use function array_map; use function array_values; use function count; +use function get_class; use function implode; use function in_array; use function sprintf; @@ -628,7 +629,7 @@ public function equals(Type $type): bool return false; } - if ($type instanceof EnumCaseObjectType) { + if (get_class($type) !== static::class) { return false; } diff --git a/tests/PHPStan/Rules/Comparison/ImpossibleCheckTypeFunctionCallRuleTest.php b/tests/PHPStan/Rules/Comparison/ImpossibleCheckTypeFunctionCallRuleTest.php index 69e992139d8..5b04c59f08b 100644 --- a/tests/PHPStan/Rules/Comparison/ImpossibleCheckTypeFunctionCallRuleTest.php +++ b/tests/PHPStan/Rules/Comparison/ImpossibleCheckTypeFunctionCallRuleTest.php @@ -1207,4 +1207,11 @@ public function testBug13799(): void ]); } + #[RequiresPhp('>= 8.1')] + public function testBug14429(): void + { + $this->treatPhpDocTypesAsCertain = false; + $this->analyse([__DIR__ . '/data/bug-14429.php'], []); + } + } diff --git a/tests/PHPStan/Rules/Comparison/data/bug-14429.php b/tests/PHPStan/Rules/Comparison/data/bug-14429.php new file mode 100644 index 00000000000..173d62c02fd --- /dev/null +++ b/tests/PHPStan/Rules/Comparison/data/bug-14429.php @@ -0,0 +1,43 @@ += 8.1 + +declare(strict_types = 1); + +namespace Bug14429; + +function throw_if(bool $condition, string $message): void +{ + if ($condition) { throw new \Exception($message); } +} + +class Foo +{ + /** + * @param list $tags + * @param list $scores + * @param \ArrayObject $stringMap + * @param \ArrayObject $intKeyMap + */ + public function __construct( + public array $tags, + public array $scores, + public ?\ArrayObject $stringMap = null, + public ?\ArrayObject $intKeyMap = null, + ) { + foreach ($tags as $tagsItem) { + throw_if(!is_string($tagsItem), 'tags item must be string'); + } + foreach ($scores as $scoresItem) { + throw_if(!is_int($scoresItem) && !is_float($scoresItem), 'scores item must be number'); + } + if ($stringMap !== null) { + foreach ($stringMap as $stringMapValue) { + throw_if(!is_string($stringMapValue), 'stringMap value must be string'); + } + } + if ($intKeyMap !== null) { + foreach ($intKeyMap as $intKeyMapValue) { + throw_if(!is_int($intKeyMapValue), 'intKeyMap value must be int'); + } + } + } +} From f057303b055238b10fb679f29a0c7593c890433b Mon Sep 17 00:00:00 2001 From: Vincent Langlet Date: Sat, 4 Apr 2026 00:52:59 +0200 Subject: [PATCH 2/8] Add test --- .../VarTagChangedExpressionTypeRuleTest.php | 5 ++ tests/PHPStan/Rules/PhpDoc/data/bug-14429.php | 87 +++++++++++++++++++ 2 files changed, 92 insertions(+) create mode 100644 tests/PHPStan/Rules/PhpDoc/data/bug-14429.php diff --git a/tests/PHPStan/Rules/PhpDoc/VarTagChangedExpressionTypeRuleTest.php b/tests/PHPStan/Rules/PhpDoc/VarTagChangedExpressionTypeRuleTest.php index e6008951aa4..63e7d0199c3 100644 --- a/tests/PHPStan/Rules/PhpDoc/VarTagChangedExpressionTypeRuleTest.php +++ b/tests/PHPStan/Rules/PhpDoc/VarTagChangedExpressionTypeRuleTest.php @@ -79,6 +79,11 @@ public function testBug10130(): void ]); } + public function testBug14429(): void + { + $this->analyse([__DIR__ . '/data/bug-14429.php'], []); + } + public function testBug12708(): void { $this->analyse([__DIR__ . '/data/bug-12708.php'], [ diff --git a/tests/PHPStan/Rules/PhpDoc/data/bug-14429.php b/tests/PHPStan/Rules/PhpDoc/data/bug-14429.php new file mode 100644 index 00000000000..aeef50ca3c7 --- /dev/null +++ b/tests/PHPStan/Rules/PhpDoc/data/bug-14429.php @@ -0,0 +1,87 @@ + + */ + public function getRepository(): IRepository; +} + +interface IProperty {} + +interface IPropertyContainer extends IProperty {} + +/** + * @template E of IEntity + */ +interface IEntityAwareProperty extends IProperty {} + +/** + * @template E of IEntity + * @extends IEntityAwareProperty + */ +interface IRelationshipContainer extends IPropertyContainer, IEntityAwareProperty {} + +interface IModel { + /** + * @template E of IEntity + * @template T of IRepository + * @param class-string $className + * @return T + */ + public function getRepository(string $className): IRepository; +} + +/** + * @template E of IEntity + */ +interface IRepository { + public function getModel(): IModel; +} + +class PropertyRelationshipMetadata { + /** @var class-string> */ + public string $repository; +} + +/** + * @template E of IEntity + * @implements IRelationshipContainer + */ +class HasOne implements IRelationshipContainer +{ + /** @var E|null */ + protected ?IEntity $parent = null; + + /** @var IRepository|null */ + protected ?IRepository $targetRepository = null; + + protected PropertyRelationshipMetadata $metadataRelationship; + + /** + * @return E + */ + protected function getParentEntity(): IEntity + { + return $this->parent ?? throw new \InvalidArgumentException('Relationship is not attached to a parent entity.'); + } + + /** + * @return IRepository + */ + protected function getTargetRepository(): IRepository + { + if ($this->targetRepository === null) { + /** @var IRepository $targetRepository */ + $targetRepository = $this->getParentEntity() + ->getRepository() + ->getModel() + ->getRepository($this->metadataRelationship->repository); + $this->targetRepository = $targetRepository; + } + + return $this->targetRepository; + } +} From 58ba5f6d895135cbf4aebc96390cc9b0593694a8 Mon Sep 17 00:00:00 2001 From: Markus Staab Date: Sat, 4 Apr 2026 09:43:20 +0200 Subject: [PATCH 3/8] fix test --- .../Rules/PhpDoc/VarTagChangedExpressionTypeRuleTest.php | 5 ----- .../Rules/PhpDoc/WrongVariableNameInVarTagRuleTest.php | 8 ++++++++ 2 files changed, 8 insertions(+), 5 deletions(-) diff --git a/tests/PHPStan/Rules/PhpDoc/VarTagChangedExpressionTypeRuleTest.php b/tests/PHPStan/Rules/PhpDoc/VarTagChangedExpressionTypeRuleTest.php index 63e7d0199c3..e6008951aa4 100644 --- a/tests/PHPStan/Rules/PhpDoc/VarTagChangedExpressionTypeRuleTest.php +++ b/tests/PHPStan/Rules/PhpDoc/VarTagChangedExpressionTypeRuleTest.php @@ -79,11 +79,6 @@ public function testBug10130(): void ]); } - public function testBug14429(): void - { - $this->analyse([__DIR__ . '/data/bug-14429.php'], []); - } - public function testBug12708(): void { $this->analyse([__DIR__ . '/data/bug-12708.php'], [ diff --git a/tests/PHPStan/Rules/PhpDoc/WrongVariableNameInVarTagRuleTest.php b/tests/PHPStan/Rules/PhpDoc/WrongVariableNameInVarTagRuleTest.php index 838211838ef..599428ee863 100644 --- a/tests/PHPStan/Rules/PhpDoc/WrongVariableNameInVarTagRuleTest.php +++ b/tests/PHPStan/Rules/PhpDoc/WrongVariableNameInVarTagRuleTest.php @@ -586,6 +586,14 @@ public function testBug12457(): void ]); } + public function testBug14429(): void + { + $this->checkTypeAgainstPhpDocType = true; + $this->strictWideningCheck = true; + $this->analyse([__DIR__ . '/data/bug-14429.php'], []); + } + + public function testNewIsAlwaysFinalClass(): void { $this->checkTypeAgainstPhpDocType = true; From ffdd55685e80330fccf62d6bc3ad08f956864145 Mon Sep 17 00:00:00 2001 From: phpstan-bot Date: Sat, 4 Apr 2026 10:10:20 +0000 Subject: [PATCH 4/8] Fix ObjectType::equals() regression with template types The previous fix using `get_class($type) !== static::class` was too strict - it prevented TemplateObjectType from being considered equal to ObjectType, breaking invariant generic type variance checks. Narrow the check to only reject GenericObjectType and EnumCaseObjectType when the concrete classes differ, preserving template type compatibility. Co-Authored-By: Claude Opus 4.6 --- phpstan-baseline.neon | 8 +++++++- src/Type/ObjectType.php | 2 +- 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/phpstan-baseline.neon b/phpstan-baseline.neon index a6edd1ace17..66fa2f67adc 100644 --- a/phpstan-baseline.neon +++ b/phpstan-baseline.neon @@ -1474,11 +1474,17 @@ parameters: path: src/Type/ObjectShapeType.php - - rawMessage: Doing instanceof PHPStan\Type\Generic\GenericObjectType is error-prone and deprecated. + rawMessage: 'Doing instanceof PHPStan\Type\Enum\EnumCaseObjectType is error-prone and deprecated. Use Type::getEnumCases() instead.' identifier: phpstanApi.instanceofType count: 1 path: src/Type/ObjectType.php + - + rawMessage: Doing instanceof PHPStan\Type\Generic\GenericObjectType is error-prone and deprecated. + identifier: phpstanApi.instanceofType + count: 2 + path: src/Type/ObjectType.php + - rawMessage: 'Doing instanceof PHPStan\Type\ObjectType is error-prone and deprecated. Use Type::isObject() or Type::getObjectClassNames() instead.' identifier: phpstanApi.instanceofType diff --git a/src/Type/ObjectType.php b/src/Type/ObjectType.php index 9ecbbe44c8d..fe043c2e2e0 100644 --- a/src/Type/ObjectType.php +++ b/src/Type/ObjectType.php @@ -629,7 +629,7 @@ public function equals(Type $type): bool return false; } - if (get_class($type) !== static::class) { + if (($type instanceof EnumCaseObjectType || $type instanceof GenericObjectType) && get_class($this) !== get_class($type)) { return false; } From 3e1b2d6d9355bf38b6eefd809fa0b6f3a56245c1 Mon Sep 17 00:00:00 2001 From: phpstan-bot Date: Sat, 4 Apr 2026 10:36:54 +0000 Subject: [PATCH 5/8] Use strict get_class() check in ObjectType::equals() and fix invariant variance for template types Instead of narrowly rejecting only EnumCaseObjectType and GenericObjectType in ObjectType::equals(), use a strict get_class() comparison for all ObjectType subclasses. This makes equals() properly symmetric and prevents any ObjectType subclass from being considered equal to a plain ObjectType. To preserve the correct behavior where IRepository remains a valid subtype of IRepository, add a template type bound check in TemplateTypeVariance::isValidVariance() for invariant positions: when the actual type argument ($b) is a TemplateType whose bound equals the expected type argument ($a), treat them as equal. Co-Authored-By: Claude Opus 4.6 --- phpstan-baseline.neon | 8 +------- src/Type/Generic/TemplateTypeVariance.php | 3 +++ src/Type/ObjectType.php | 2 +- 3 files changed, 5 insertions(+), 8 deletions(-) diff --git a/phpstan-baseline.neon b/phpstan-baseline.neon index 66fa2f67adc..a6edd1ace17 100644 --- a/phpstan-baseline.neon +++ b/phpstan-baseline.neon @@ -1473,16 +1473,10 @@ parameters: count: 1 path: src/Type/ObjectShapeType.php - - - rawMessage: 'Doing instanceof PHPStan\Type\Enum\EnumCaseObjectType is error-prone and deprecated. Use Type::getEnumCases() instead.' - identifier: phpstanApi.instanceofType - count: 1 - path: src/Type/ObjectType.php - - rawMessage: Doing instanceof PHPStan\Type\Generic\GenericObjectType is error-prone and deprecated. identifier: phpstanApi.instanceofType - count: 2 + count: 1 path: src/Type/ObjectType.php - diff --git a/src/Type/Generic/TemplateTypeVariance.php b/src/Type/Generic/TemplateTypeVariance.php index 35536317dd6..f3b193214e2 100644 --- a/src/Type/Generic/TemplateTypeVariance.php +++ b/src/Type/Generic/TemplateTypeVariance.php @@ -177,6 +177,9 @@ public function isValidVariance(TemplateType $templateType, Type $a, Type $b): I if ($this->invariant()) { $result = $a->equals($b); + if (!$result && $b instanceof TemplateType && $a->equals($b->getBound())) { + $result = true; + } $reasons = []; if (!$result) { if ( diff --git a/src/Type/ObjectType.php b/src/Type/ObjectType.php index fe043c2e2e0..df4be1113f4 100644 --- a/src/Type/ObjectType.php +++ b/src/Type/ObjectType.php @@ -629,7 +629,7 @@ public function equals(Type $type): bool return false; } - if (($type instanceof EnumCaseObjectType || $type instanceof GenericObjectType) && get_class($this) !== get_class($type)) { + if (get_class($this) !== get_class($type)) { return false; } From df5ee1059f63e0dfcab01c705108f8c0e0a4fa00 Mon Sep 17 00:00:00 2001 From: phpstan-bot Date: Sat, 4 Apr 2026 11:11:28 +0000 Subject: [PATCH 6/8] Move generic type arg check from TemplateTypeVariance to VarTagTypeRuleHelper Revert the TemplateTypeVariance hack that treated template types as equal to their bounds for invariant checks - this was conceptually wrong since invariant means exact type equality. Instead, fix VarTagTypeRuleHelper::checkType() to compare generic object type arguments individually (covariantly) when both types are the same generic class. This correctly allows @var tags like `@var IRepository` or `@var IRepository` when the expression type is `IRepository` and E/Foo are subtypes of IEntity. Co-Authored-By: Claude Opus 4.6 --- phpstan-baseline.neon | 2 +- src/Rules/PhpDoc/VarTagTypeRuleHelper.php | 17 +++++++++++++++++ src/Type/Generic/TemplateTypeVariance.php | 3 --- 3 files changed, 18 insertions(+), 4 deletions(-) diff --git a/phpstan-baseline.neon b/phpstan-baseline.neon index a6edd1ace17..ee9699990c5 100644 --- a/phpstan-baseline.neon +++ b/phpstan-baseline.neon @@ -726,7 +726,7 @@ parameters: - rawMessage: Doing instanceof PHPStan\Type\Generic\GenericObjectType is error-prone and deprecated. identifier: phpstanApi.instanceofType - count: 1 + count: 3 path: src/Rules/PhpDoc/VarTagTypeRuleHelper.php - diff --git a/src/Rules/PhpDoc/VarTagTypeRuleHelper.php b/src/Rules/PhpDoc/VarTagTypeRuleHelper.php index 0e6ab708b28..a2e920fdea6 100644 --- a/src/Rules/PhpDoc/VarTagTypeRuleHelper.php +++ b/src/Rules/PhpDoc/VarTagTypeRuleHelper.php @@ -177,6 +177,23 @@ private function shouldVarTagTypeBeReported(Scope $scope, Node\Expr $expr, Type private function checkType(Scope $scope, Type $type, Type $varTagType, int $depth = 0): bool { + if ( + $type instanceof GenericObjectType + && $varTagType instanceof GenericObjectType + && $type->getClassName() === $varTagType->getClassName() + ) { + $typeArgs = $type->getTypes(); + $varTagArgs = $varTagType->getTypes(); + if (count($typeArgs) === count($varTagArgs)) { + foreach ($typeArgs as $i => $typeArg) { + if ($this->checkType($scope, $typeArg, $varTagArgs[$i], $depth + 1)) { + return true; + } + } + return false; + } + } + if ($this->strictWideningCheck) { return !$this->isSuperTypeOfVarType($scope, $type, $varTagType); } diff --git a/src/Type/Generic/TemplateTypeVariance.php b/src/Type/Generic/TemplateTypeVariance.php index f3b193214e2..35536317dd6 100644 --- a/src/Type/Generic/TemplateTypeVariance.php +++ b/src/Type/Generic/TemplateTypeVariance.php @@ -177,9 +177,6 @@ public function isValidVariance(TemplateType $templateType, Type $a, Type $b): I if ($this->invariant()) { $result = $a->equals($b); - if (!$result && $b instanceof TemplateType && $a->equals($b->getBound())) { - $result = true; - } $reasons = []; if (!$result) { if ( From d8f11d9ee8b855c97a2922175d4986f95d87f3ba Mon Sep 17 00:00:00 2001 From: phpstan-bot Date: Sat, 4 Apr 2026 12:15:51 +0000 Subject: [PATCH 7/8] Move template type bound check from VarTagTypeRuleHelper to TemplateTypeVariance Instead of special-casing GenericObjectType in VarTagTypeRuleHelper, handle the template type equivalence at the type system level: in the invariant variance check, when the other type ($b) is a TemplateType whose bound equals $a, consider them equivalent. This approach uses the existing $acceptsContext infrastructure direction rather than adding rule-level workarounds. Co-Authored-By: Claude Opus 4.6 --- phpstan-baseline.neon | 2 +- src/Rules/PhpDoc/VarTagTypeRuleHelper.php | 17 ----------------- src/Type/Generic/TemplateTypeVariance.php | 3 +++ 3 files changed, 4 insertions(+), 18 deletions(-) diff --git a/phpstan-baseline.neon b/phpstan-baseline.neon index ee9699990c5..a6edd1ace17 100644 --- a/phpstan-baseline.neon +++ b/phpstan-baseline.neon @@ -726,7 +726,7 @@ parameters: - rawMessage: Doing instanceof PHPStan\Type\Generic\GenericObjectType is error-prone and deprecated. identifier: phpstanApi.instanceofType - count: 3 + count: 1 path: src/Rules/PhpDoc/VarTagTypeRuleHelper.php - diff --git a/src/Rules/PhpDoc/VarTagTypeRuleHelper.php b/src/Rules/PhpDoc/VarTagTypeRuleHelper.php index a2e920fdea6..0e6ab708b28 100644 --- a/src/Rules/PhpDoc/VarTagTypeRuleHelper.php +++ b/src/Rules/PhpDoc/VarTagTypeRuleHelper.php @@ -177,23 +177,6 @@ private function shouldVarTagTypeBeReported(Scope $scope, Node\Expr $expr, Type private function checkType(Scope $scope, Type $type, Type $varTagType, int $depth = 0): bool { - if ( - $type instanceof GenericObjectType - && $varTagType instanceof GenericObjectType - && $type->getClassName() === $varTagType->getClassName() - ) { - $typeArgs = $type->getTypes(); - $varTagArgs = $varTagType->getTypes(); - if (count($typeArgs) === count($varTagArgs)) { - foreach ($typeArgs as $i => $typeArg) { - if ($this->checkType($scope, $typeArg, $varTagArgs[$i], $depth + 1)) { - return true; - } - } - return false; - } - } - if ($this->strictWideningCheck) { return !$this->isSuperTypeOfVarType($scope, $type, $varTagType); } diff --git a/src/Type/Generic/TemplateTypeVariance.php b/src/Type/Generic/TemplateTypeVariance.php index 35536317dd6..f3b193214e2 100644 --- a/src/Type/Generic/TemplateTypeVariance.php +++ b/src/Type/Generic/TemplateTypeVariance.php @@ -177,6 +177,9 @@ public function isValidVariance(TemplateType $templateType, Type $a, Type $b): I if ($this->invariant()) { $result = $a->equals($b); + if (!$result && $b instanceof TemplateType && $a->equals($b->getBound())) { + $result = true; + } $reasons = []; if (!$result) { if ( From f2c9acab9eb0f76f2393c0c65242130b539a783f Mon Sep 17 00:00:00 2001 From: Vincent Langlet Date: Sat, 4 Apr 2026 14:19:46 +0200 Subject: [PATCH 8/8] Simplify --- src/Type/Generic/TemplateTypeVariance.php | 5 +---- src/Type/ObjectType.php | 4 ---- 2 files changed, 1 insertion(+), 8 deletions(-) diff --git a/src/Type/Generic/TemplateTypeVariance.php b/src/Type/Generic/TemplateTypeVariance.php index f3b193214e2..2d4ed549f97 100644 --- a/src/Type/Generic/TemplateTypeVariance.php +++ b/src/Type/Generic/TemplateTypeVariance.php @@ -176,10 +176,7 @@ public function isValidVariance(TemplateType $templateType, Type $a, Type $b): I } if ($this->invariant()) { - $result = $a->equals($b); - if (!$result && $b instanceof TemplateType && $a->equals($b->getBound())) { - $result = true; - } + $result = $a->equals($b) || ($b instanceof TemplateType && $a->equals($b->getBound())); $reasons = []; if (!$result) { if ( diff --git a/src/Type/ObjectType.php b/src/Type/ObjectType.php index df4be1113f4..3d81d774103 100644 --- a/src/Type/ObjectType.php +++ b/src/Type/ObjectType.php @@ -625,10 +625,6 @@ public function isSuperTypeOf(Type $type): IsSuperTypeOfResult public function equals(Type $type): bool { - if (!$type instanceof self) { - return false; - } - if (get_class($this) !== get_class($type)) { return false; }