From da1012fdf514bf225378d62f5b6bd20ef721e46f Mon Sep 17 00:00:00 2001 From: ondrejmirtes <104888+ondrejmirtes@users.noreply.github.com> Date: Tue, 14 Apr 2026 07:32:57 +0000 Subject: [PATCH 1/6] Generalize `dynamicConstantNames` constants whose type cannot be widened by `generalize()` to `mixed` - NullType::generalize() returns $this because null has no broader type family, so dynamicConstantNames had no effect on null-valued constants - After calling generalize(), check if the result is still a constant value; if so, return MixedType instead - Fix applies to both resolveConstantType (global constants) and resolveClassConstantType (class constants) - Also fixes the analogous case for empty ConstantArrayType ([]), whose generalize() also returns $this --- src/Analyser/ConstantResolver.php | 12 ++++++++++-- tests/PHPStan/Analyser/data/dynamic-constant.php | 16 ++++++++++++++++ tests/PHPStan/Analyser/dynamic-constants.neon | 5 +++++ 3 files changed, 31 insertions(+), 2 deletions(-) diff --git a/src/Analyser/ConstantResolver.php b/src/Analyser/ConstantResolver.php index 368f01a0986..b0dd7247930 100644 --- a/src/Analyser/ConstantResolver.php +++ b/src/Analyser/ConstantResolver.php @@ -424,7 +424,11 @@ public function resolveConstantType(string $constantName, Type $constantType): T return $constantType; } if (in_array($constantName, $this->dynamicConstantNames, true)) { - return $constantType->generalize(GeneralizePrecision::lessSpecific()); + $generalized = $constantType->generalize(GeneralizePrecision::lessSpecific()); + if ($generalized->isConstantValue()->yes()) { + return new MixedType(); + } + return $generalized; } } @@ -455,7 +459,11 @@ public function resolveClassConstantType(string $className, string $constantName } if ($constantType->isConstantValue()->yes()) { - return $constantType->generalize(GeneralizePrecision::lessSpecific()); + $generalized = $constantType->generalize(GeneralizePrecision::lessSpecific()); + if ($generalized->isConstantValue()->yes()) { + return new MixedType(); + } + return $generalized; } } diff --git a/tests/PHPStan/Analyser/data/dynamic-constant.php b/tests/PHPStan/Analyser/data/dynamic-constant.php index eb42e33c531..176d917f1f9 100644 --- a/tests/PHPStan/Analyser/data/dynamic-constant.php +++ b/tests/PHPStan/Analyser/data/dynamic-constant.php @@ -7,12 +7,17 @@ define('GLOBAL_PURE_CONSTANT', 123); define('GLOBAL_DYNAMIC_CONSTANT', false); define('GLOBAL_DYNAMIC_CONSTANT_WITH_EXPLICIT_TYPES', null); +define('GLOBAL_DYNAMIC_NULL_CONSTANT', null); class DynamicConstantClass { const DYNAMIC_CONSTANT_IN_CLASS = 'abcdef'; const DYNAMIC_CONSTANT_WITH_EXPLICIT_TYPES_IN_CLASS = 'xyz'; const PURE_CONSTANT_IN_CLASS = 'abc123def'; + const DYNAMIC_NULL_CONSTANT = null; + const DYNAMIC_TRUE_CONSTANT = true; + const DYNAMIC_FALSE_CONSTANT = false; + const DYNAMIC_EMPTY_ARRAY_CONSTANT = []; } class NoDynamicConstantClass @@ -29,5 +34,16 @@ private function rip() assertType('bool', GLOBAL_DYNAMIC_CONSTANT); assertType('123', GLOBAL_PURE_CONSTANT); assertType('string|null', GLOBAL_DYNAMIC_CONSTANT_WITH_EXPLICIT_TYPES); + + // Bug 9218: dynamicConstantNames with null value + assertType('mixed', DynamicConstantClass::DYNAMIC_NULL_CONSTANT); + assertType('mixed', GLOBAL_DYNAMIC_NULL_CONSTANT); + + // Bool constants should generalize properly + assertType('bool', DynamicConstantClass::DYNAMIC_TRUE_CONSTANT); + assertType('bool', DynamicConstantClass::DYNAMIC_FALSE_CONSTANT); + + // Empty array constant should generalize to mixed + assertType('mixed', DynamicConstantClass::DYNAMIC_EMPTY_ARRAY_CONSTANT); } } diff --git a/tests/PHPStan/Analyser/dynamic-constants.neon b/tests/PHPStan/Analyser/dynamic-constants.neon index 43dfa166772..f10c7ecbd75 100644 --- a/tests/PHPStan/Analyser/dynamic-constants.neon +++ b/tests/PHPStan/Analyser/dynamic-constants.neon @@ -4,6 +4,11 @@ includes: parameters: dynamicConstantNames: - DynamicConstants\DynamicConstantClass::DYNAMIC_CONSTANT_IN_CLASS + - DynamicConstants\DynamicConstantClass::DYNAMIC_NULL_CONSTANT + - DynamicConstants\DynamicConstantClass::DYNAMIC_TRUE_CONSTANT + - DynamicConstants\DynamicConstantClass::DYNAMIC_FALSE_CONSTANT + - DynamicConstants\DynamicConstantClass::DYNAMIC_EMPTY_ARRAY_CONSTANT - GLOBAL_DYNAMIC_CONSTANT + - GLOBAL_DYNAMIC_NULL_CONSTANT DynamicConstants\DynamicConstantClass::DYNAMIC_CONSTANT_WITH_EXPLICIT_TYPES_IN_CLASS: 'string|null' GLOBAL_DYNAMIC_CONSTANT_WITH_EXPLICIT_TYPES: 'string|null' From dddb9a8eb9dfcd69b68ac2841ae665f3345c4ec4 Mon Sep 17 00:00:00 2001 From: phpstan-bot Date: Wed, 15 Apr 2026 09:30:29 +0000 Subject: [PATCH 2/6] Use @var PHPDoc type for dynamicConstantNames instead of falling back to mixed When a class constant has a @var PHPDoc type annotation and is listed in dynamicConstantNames, use the PHPDoc type instead of generalizing the literal value. This fixes the primary use case from phpstan/phpstan#9218 where `@var string|null` on a null-valued constant was ignored. The fix adds a $phpDocType parameter to resolveClassConstantType() and passes it from both call sites in InitializerExprTypeResolver. The priority is: native type > PHPDoc type > generalized value > mixed. Co-Authored-By: Claude Opus 4.6 --- src/Analyser/ConstantResolver.php | 6 +++++- src/Reflection/InitializerExprTypeResolver.php | 3 +++ tests/PHPStan/Analyser/data/dynamic-constant.php | 10 ++++++++++ tests/PHPStan/Analyser/dynamic-constants.neon | 2 ++ 4 files changed, 20 insertions(+), 1 deletion(-) diff --git a/src/Analyser/ConstantResolver.php b/src/Analyser/ConstantResolver.php index b0dd7247930..c6d6c0f70d6 100644 --- a/src/Analyser/ConstantResolver.php +++ b/src/Analyser/ConstantResolver.php @@ -435,7 +435,7 @@ public function resolveConstantType(string $constantName, Type $constantType): T return $constantType; } - public function resolveClassConstantType(string $className, string $constantName, Type $constantType, ?Type $nativeType): Type + public function resolveClassConstantType(string $className, string $constantName, Type $constantType, ?Type $nativeType, ?Type $phpDocType): Type { $lookupConstantName = sprintf('%s::%s', $className, $constantName); if (array_key_exists($lookupConstantName, $this->dynamicConstantNames)) { @@ -458,6 +458,10 @@ public function resolveClassConstantType(string $className, string $constantName return $nativeType; } + if ($phpDocType !== null) { + return $phpDocType; + } + if ($constantType->isConstantValue()->yes()) { $generalized = $constantType->generalize(GeneralizePrecision::lessSpecific()); if ($generalized->isConstantValue()->yes()) { diff --git a/src/Reflection/InitializerExprTypeResolver.php b/src/Reflection/InitializerExprTypeResolver.php index b4578930acf..de9649776c0 100644 --- a/src/Reflection/InitializerExprTypeResolver.php +++ b/src/Reflection/InitializerExprTypeResolver.php @@ -2544,11 +2544,13 @@ function (Type $type, callable $traverse): Type { if ($reflectionConstant->getType() !== null) { $nativeType = TypehintHelper::decideTypeFromReflection($reflectionConstant->getType(), selfClass: $constantClassReflection); } + $phpDocType = $constantClassReflection->getConstant($constantName)->getPhpDocType(); $types[] = $this->constantResolver->resolveClassConstantType( $constantClassReflection->getName(), $constantName, $constantType, $nativeType, + $phpDocType, ); unset($this->currentlyResolvingClassConstant[$resolvingName]); continue; @@ -2577,6 +2579,7 @@ function (Type $type, callable $traverse): Type { $constantName, $constantType, $nativeType, + $constantReflection->getPhpDocType(), ); unset($this->currentlyResolvingClassConstant[$resolvingName]); $types[] = $constantType; diff --git a/tests/PHPStan/Analyser/data/dynamic-constant.php b/tests/PHPStan/Analyser/data/dynamic-constant.php index 176d917f1f9..561537347ad 100644 --- a/tests/PHPStan/Analyser/data/dynamic-constant.php +++ b/tests/PHPStan/Analyser/data/dynamic-constant.php @@ -18,6 +18,12 @@ class DynamicConstantClass const DYNAMIC_TRUE_CONSTANT = true; const DYNAMIC_FALSE_CONSTANT = false; const DYNAMIC_EMPTY_ARRAY_CONSTANT = []; + + /** @var string|null */ + const DYNAMIC_NULL_WITH_PHPDOC_CONSTANT = null; + + /** @var list */ + const DYNAMIC_EMPTY_ARRAY_WITH_PHPDOC_CONSTANT = []; } class NoDynamicConstantClass @@ -45,5 +51,9 @@ private function rip() // Empty array constant should generalize to mixed assertType('mixed', DynamicConstantClass::DYNAMIC_EMPTY_ARRAY_CONSTANT); + + // Bug 9218: dynamicConstantNames with @var PHPDoc type + assertType('string|null', DynamicConstantClass::DYNAMIC_NULL_WITH_PHPDOC_CONSTANT); + assertType('list', DynamicConstantClass::DYNAMIC_EMPTY_ARRAY_WITH_PHPDOC_CONSTANT); } } diff --git a/tests/PHPStan/Analyser/dynamic-constants.neon b/tests/PHPStan/Analyser/dynamic-constants.neon index f10c7ecbd75..094c5415fa2 100644 --- a/tests/PHPStan/Analyser/dynamic-constants.neon +++ b/tests/PHPStan/Analyser/dynamic-constants.neon @@ -8,6 +8,8 @@ parameters: - DynamicConstants\DynamicConstantClass::DYNAMIC_TRUE_CONSTANT - DynamicConstants\DynamicConstantClass::DYNAMIC_FALSE_CONSTANT - DynamicConstants\DynamicConstantClass::DYNAMIC_EMPTY_ARRAY_CONSTANT + - DynamicConstants\DynamicConstantClass::DYNAMIC_NULL_WITH_PHPDOC_CONSTANT + - DynamicConstants\DynamicConstantClass::DYNAMIC_EMPTY_ARRAY_WITH_PHPDOC_CONSTANT - GLOBAL_DYNAMIC_CONSTANT - GLOBAL_DYNAMIC_NULL_CONSTANT DynamicConstants\DynamicConstantClass::DYNAMIC_CONSTANT_WITH_EXPLICIT_TYPES_IN_CLASS: 'string|null' From 62e05148ed3f5d12aa7c44a7f6dfa78e5167d5d6 Mon Sep 17 00:00:00 2001 From: phpstan-bot Date: Wed, 15 Apr 2026 09:53:30 +0000 Subject: [PATCH 3/6] Revert generalize-to-mixed fallback, keep only PHPDoc type support The issue was about using the @var PHPDoc type when provided, not about generalizing ungeneralizable constants to mixed. Revert the isConstantValue()->yes() check after generalize() in both resolveConstantType() and resolveClassConstantType(), restoring the original generalize() behavior. Co-Authored-By: Claude Opus 4.6 --- src/Analyser/ConstantResolver.php | 12 ++---------- tests/PHPStan/Analyser/data/dynamic-constant.php | 10 +++++----- 2 files changed, 7 insertions(+), 15 deletions(-) diff --git a/src/Analyser/ConstantResolver.php b/src/Analyser/ConstantResolver.php index c6d6c0f70d6..52f3b515e3f 100644 --- a/src/Analyser/ConstantResolver.php +++ b/src/Analyser/ConstantResolver.php @@ -424,11 +424,7 @@ public function resolveConstantType(string $constantName, Type $constantType): T return $constantType; } if (in_array($constantName, $this->dynamicConstantNames, true)) { - $generalized = $constantType->generalize(GeneralizePrecision::lessSpecific()); - if ($generalized->isConstantValue()->yes()) { - return new MixedType(); - } - return $generalized; + return $constantType->generalize(GeneralizePrecision::lessSpecific()); } } @@ -463,11 +459,7 @@ public function resolveClassConstantType(string $className, string $constantName } if ($constantType->isConstantValue()->yes()) { - $generalized = $constantType->generalize(GeneralizePrecision::lessSpecific()); - if ($generalized->isConstantValue()->yes()) { - return new MixedType(); - } - return $generalized; + return $constantType->generalize(GeneralizePrecision::lessSpecific()); } } diff --git a/tests/PHPStan/Analyser/data/dynamic-constant.php b/tests/PHPStan/Analyser/data/dynamic-constant.php index 561537347ad..4b5a781cc67 100644 --- a/tests/PHPStan/Analyser/data/dynamic-constant.php +++ b/tests/PHPStan/Analyser/data/dynamic-constant.php @@ -41,16 +41,16 @@ private function rip() assertType('123', GLOBAL_PURE_CONSTANT); assertType('string|null', GLOBAL_DYNAMIC_CONSTANT_WITH_EXPLICIT_TYPES); - // Bug 9218: dynamicConstantNames with null value - assertType('mixed', DynamicConstantClass::DYNAMIC_NULL_CONSTANT); - assertType('mixed', GLOBAL_DYNAMIC_NULL_CONSTANT); + // dynamicConstantNames with null value (no PHPDoc) + assertType('null', DynamicConstantClass::DYNAMIC_NULL_CONSTANT); + assertType('null', GLOBAL_DYNAMIC_NULL_CONSTANT); // Bool constants should generalize properly assertType('bool', DynamicConstantClass::DYNAMIC_TRUE_CONSTANT); assertType('bool', DynamicConstantClass::DYNAMIC_FALSE_CONSTANT); - // Empty array constant should generalize to mixed - assertType('mixed', DynamicConstantClass::DYNAMIC_EMPTY_ARRAY_CONSTANT); + // Empty array constant (no PHPDoc) + assertType('array{}', DynamicConstantClass::DYNAMIC_EMPTY_ARRAY_CONSTANT); // Bug 9218: dynamicConstantNames with @var PHPDoc type assertType('string|null', DynamicConstantClass::DYNAMIC_NULL_WITH_PHPDOC_CONSTANT); From e8939f47ec1821b34da1d7e954b4186a603ce97a Mon Sep 17 00:00:00 2001 From: phpstan-bot Date: Wed, 15 Apr 2026 10:17:13 +0000 Subject: [PATCH 4/6] Remove tests for constants without PHPDoc (null, true, false, empty array) Keep only the PHPDoc-annotated constant tests, which are the actual fix for phpstan/phpstan#9218. Co-Authored-By: Claude Opus 4.6 --- tests/PHPStan/Analyser/data/dynamic-constant.php | 16 ---------------- tests/PHPStan/Analyser/dynamic-constants.neon | 5 ----- 2 files changed, 21 deletions(-) diff --git a/tests/PHPStan/Analyser/data/dynamic-constant.php b/tests/PHPStan/Analyser/data/dynamic-constant.php index 4b5a781cc67..17f2c027dea 100644 --- a/tests/PHPStan/Analyser/data/dynamic-constant.php +++ b/tests/PHPStan/Analyser/data/dynamic-constant.php @@ -7,17 +7,12 @@ define('GLOBAL_PURE_CONSTANT', 123); define('GLOBAL_DYNAMIC_CONSTANT', false); define('GLOBAL_DYNAMIC_CONSTANT_WITH_EXPLICIT_TYPES', null); -define('GLOBAL_DYNAMIC_NULL_CONSTANT', null); class DynamicConstantClass { const DYNAMIC_CONSTANT_IN_CLASS = 'abcdef'; const DYNAMIC_CONSTANT_WITH_EXPLICIT_TYPES_IN_CLASS = 'xyz'; const PURE_CONSTANT_IN_CLASS = 'abc123def'; - const DYNAMIC_NULL_CONSTANT = null; - const DYNAMIC_TRUE_CONSTANT = true; - const DYNAMIC_FALSE_CONSTANT = false; - const DYNAMIC_EMPTY_ARRAY_CONSTANT = []; /** @var string|null */ const DYNAMIC_NULL_WITH_PHPDOC_CONSTANT = null; @@ -41,17 +36,6 @@ private function rip() assertType('123', GLOBAL_PURE_CONSTANT); assertType('string|null', GLOBAL_DYNAMIC_CONSTANT_WITH_EXPLICIT_TYPES); - // dynamicConstantNames with null value (no PHPDoc) - assertType('null', DynamicConstantClass::DYNAMIC_NULL_CONSTANT); - assertType('null', GLOBAL_DYNAMIC_NULL_CONSTANT); - - // Bool constants should generalize properly - assertType('bool', DynamicConstantClass::DYNAMIC_TRUE_CONSTANT); - assertType('bool', DynamicConstantClass::DYNAMIC_FALSE_CONSTANT); - - // Empty array constant (no PHPDoc) - assertType('array{}', DynamicConstantClass::DYNAMIC_EMPTY_ARRAY_CONSTANT); - // Bug 9218: dynamicConstantNames with @var PHPDoc type assertType('string|null', DynamicConstantClass::DYNAMIC_NULL_WITH_PHPDOC_CONSTANT); assertType('list', DynamicConstantClass::DYNAMIC_EMPTY_ARRAY_WITH_PHPDOC_CONSTANT); diff --git a/tests/PHPStan/Analyser/dynamic-constants.neon b/tests/PHPStan/Analyser/dynamic-constants.neon index 094c5415fa2..fa71f32e5af 100644 --- a/tests/PHPStan/Analyser/dynamic-constants.neon +++ b/tests/PHPStan/Analyser/dynamic-constants.neon @@ -4,13 +4,8 @@ includes: parameters: dynamicConstantNames: - DynamicConstants\DynamicConstantClass::DYNAMIC_CONSTANT_IN_CLASS - - DynamicConstants\DynamicConstantClass::DYNAMIC_NULL_CONSTANT - - DynamicConstants\DynamicConstantClass::DYNAMIC_TRUE_CONSTANT - - DynamicConstants\DynamicConstantClass::DYNAMIC_FALSE_CONSTANT - - DynamicConstants\DynamicConstantClass::DYNAMIC_EMPTY_ARRAY_CONSTANT - DynamicConstants\DynamicConstantClass::DYNAMIC_NULL_WITH_PHPDOC_CONSTANT - DynamicConstants\DynamicConstantClass::DYNAMIC_EMPTY_ARRAY_WITH_PHPDOC_CONSTANT - GLOBAL_DYNAMIC_CONSTANT - - GLOBAL_DYNAMIC_NULL_CONSTANT DynamicConstants\DynamicConstantClass::DYNAMIC_CONSTANT_WITH_EXPLICIT_TYPES_IN_CLASS: 'string|null' GLOBAL_DYNAMIC_CONSTANT_WITH_EXPLICIT_TYPES: 'string|null' From f3547739b1967ffda282e7d4d2b9e31b725b14c5 Mon Sep 17 00:00:00 2001 From: phpstan-bot Date: Wed, 15 Apr 2026 11:08:20 +0000 Subject: [PATCH 5/6] Remove bug reference comment from test Co-Authored-By: Claude Opus 4.6 --- tests/PHPStan/Analyser/data/dynamic-constant.php | 2 -- 1 file changed, 2 deletions(-) diff --git a/tests/PHPStan/Analyser/data/dynamic-constant.php b/tests/PHPStan/Analyser/data/dynamic-constant.php index 17f2c027dea..7d55e24f0af 100644 --- a/tests/PHPStan/Analyser/data/dynamic-constant.php +++ b/tests/PHPStan/Analyser/data/dynamic-constant.php @@ -35,8 +35,6 @@ private function rip() assertType('bool', GLOBAL_DYNAMIC_CONSTANT); assertType('123', GLOBAL_PURE_CONSTANT); assertType('string|null', GLOBAL_DYNAMIC_CONSTANT_WITH_EXPLICIT_TYPES); - - // Bug 9218: dynamicConstantNames with @var PHPDoc type assertType('string|null', DynamicConstantClass::DYNAMIC_NULL_WITH_PHPDOC_CONSTANT); assertType('list', DynamicConstantClass::DYNAMIC_EMPTY_ARRAY_WITH_PHPDOC_CONSTANT); } From 794d8fec8f2dda5e601b1bc71aeec15bc7eb251c Mon Sep 17 00:00:00 2001 From: phpstan-bot Date: Wed, 15 Apr 2026 17:23:30 +0000 Subject: [PATCH 6/6] Add test for PHPDoc type incompatible with native value Tests that when a dynamicConstantNames constant has a @var PHPDoc type that is incompatible with its actual value (e.g. @var int on a null constant), the PHPDoc type is used as-is. Co-Authored-By: Claude Opus 4.6 --- tests/PHPStan/Analyser/data/dynamic-constant.php | 4 ++++ tests/PHPStan/Analyser/dynamic-constants.neon | 1 + 2 files changed, 5 insertions(+) diff --git a/tests/PHPStan/Analyser/data/dynamic-constant.php b/tests/PHPStan/Analyser/data/dynamic-constant.php index 7d55e24f0af..7509236df61 100644 --- a/tests/PHPStan/Analyser/data/dynamic-constant.php +++ b/tests/PHPStan/Analyser/data/dynamic-constant.php @@ -19,6 +19,9 @@ class DynamicConstantClass /** @var list */ const DYNAMIC_EMPTY_ARRAY_WITH_PHPDOC_CONSTANT = []; + + /** @var int */ + const DYNAMIC_INCOMPATIBLE_PHPDOC_CONSTANT = null; } class NoDynamicConstantClass @@ -37,5 +40,6 @@ private function rip() assertType('string|null', GLOBAL_DYNAMIC_CONSTANT_WITH_EXPLICIT_TYPES); assertType('string|null', DynamicConstantClass::DYNAMIC_NULL_WITH_PHPDOC_CONSTANT); assertType('list', DynamicConstantClass::DYNAMIC_EMPTY_ARRAY_WITH_PHPDOC_CONSTANT); + assertType('int', DynamicConstantClass::DYNAMIC_INCOMPATIBLE_PHPDOC_CONSTANT); } } diff --git a/tests/PHPStan/Analyser/dynamic-constants.neon b/tests/PHPStan/Analyser/dynamic-constants.neon index fa71f32e5af..2dfb2167aff 100644 --- a/tests/PHPStan/Analyser/dynamic-constants.neon +++ b/tests/PHPStan/Analyser/dynamic-constants.neon @@ -6,6 +6,7 @@ parameters: - DynamicConstants\DynamicConstantClass::DYNAMIC_CONSTANT_IN_CLASS - DynamicConstants\DynamicConstantClass::DYNAMIC_NULL_WITH_PHPDOC_CONSTANT - DynamicConstants\DynamicConstantClass::DYNAMIC_EMPTY_ARRAY_WITH_PHPDOC_CONSTANT + - DynamicConstants\DynamicConstantClass::DYNAMIC_INCOMPATIBLE_PHPDOC_CONSTANT - GLOBAL_DYNAMIC_CONSTANT DynamicConstants\DynamicConstantClass::DYNAMIC_CONSTANT_WITH_EXPLICIT_TYPES_IN_CLASS: 'string|null' GLOBAL_DYNAMIC_CONSTANT_WITH_EXPLICIT_TYPES: 'string|null'