From a5936ed822a61b2e16e1909e06ca624e1331490f Mon Sep 17 00:00:00 2001 From: phpstan-bot <79867460+phpstan-bot@users.noreply.github.com> Date: Thu, 22 Oct 2026 19:53:00 +0000 Subject: [PATCH 1/6] Ignore `FILTER_THROW_ON_FAILURE` bit when the PHP version range only maybe includes PHP 8.5 and the runtime `FILTER_FLAG_GLOBAL_RANGE` shares it - FilterFunctionReturnTypeHelper::hasThrowOnFailureFlag() returns no when PhpVersions::hasFilterThrowOnFailureConstant() is maybe and the stubbed FILTER_THROW_ON_FAILURE value equals the runtime FILTER_FLAG_GLOBAL_RANGE value (PHP < 8.5 runtime, 0x10000000). When the scope is definitely PHP 8.5+, the flag is still detected as before. - FilterFunctionsThrowTypeExtension now uses the same hasThrowOnFailureFlag() helper, so FILTER_FLAG_GLOBAL_RANGE is no longer reported as throwing Filter\FilterFailedException on such ranges. - Probed FilterVarRule ("Cannot use both FILTER_NULL_ON_FAILURE and FILTER_THROW_ON_FAILURE"): it uses the single configured PhpVersion, which is below 8.5 for a range like 8.4-8.5, so it was not affected. --- .../Php/FilterFunctionReturnTypeHelper.php | 27 ++++++++++++++++--- .../Php/FilterFunctionsThrowTypeExtension.php | 2 +- ...p-version-range-return-type-extensions.php | 8 ++++++ ...WithUnthrownExceptionRuleConfigPhpTest.php | 4 +++ .../data/throw-type-php-versions.php | 9 +++++++ 5 files changed, 45 insertions(+), 5 deletions(-) diff --git a/src/Type/Php/FilterFunctionReturnTypeHelper.php b/src/Type/Php/FilterFunctionReturnTypeHelper.php index af128a42272..cf774b476de 100644 --- a/src/Type/Php/FilterFunctionReturnTypeHelper.php +++ b/src/Type/Php/FilterFunctionReturnTypeHelper.php @@ -515,11 +515,30 @@ public function hasFlag(string $flagName, ?Type $flagsType): TrinaryLogic ); } - private function hasThrowOnFailureFlag(?Type $flagsType, PhpVersions $phpVersions): TrinaryLogic + public function hasThrowOnFailureFlag(?Type $flagsType, PhpVersions $phpVersions): TrinaryLogic { - return $phpVersions->hasFilterThrowOnFailureConstant()->no() - ? TrinaryLogic::createNo() - : $this->hasFlag('FILTER_THROW_ON_FAILURE', $flagsType); + $hasConstant = $phpVersions->hasFilterThrowOnFailureConstant(); + if ($hasConstant->no()) { + return TrinaryLogic::createNo(); + } + + // Flag values are resolved from the runtime. Before PHP 8.5, FILTER_FLAG_GLOBAL_RANGE used the bit + // that PHP 8.5 assigned to FILTER_THROW_ON_FAILURE, so the two cannot be told apart on such runtime + // unless the analysed code definitely runs on PHP 8.5+. + if ($hasConstant->maybe() && $this->isThrowOnFailureFlagAmbiguous()) { + return TrinaryLogic::createNo(); + } + + return $this->hasFlag('FILTER_THROW_ON_FAILURE', $flagsType); + } + + private function isThrowOnFailureFlagAmbiguous(): bool + { + if (!$this->reflectionProvider->hasConstant(new Node\Name\FullyQualified('FILTER_FLAG_GLOBAL_RANGE'), null)) { + return false; + } + + return $this->getConstant('FILTER_THROW_ON_FAILURE') === $this->getConstant('FILTER_FLAG_GLOBAL_RANGE'); } private function getFlagsValue(Type $exprType): Type diff --git a/src/Type/Php/FilterFunctionsThrowTypeExtension.php b/src/Type/Php/FilterFunctionsThrowTypeExtension.php index 6d5d78610be..c08d71946ba 100644 --- a/src/Type/Php/FilterFunctionsThrowTypeExtension.php +++ b/src/Type/Php/FilterFunctionsThrowTypeExtension.php @@ -45,7 +45,7 @@ public function getThrowTypeFromFunctionCall( } foreach ($this->filterFunctionFlagsHelper->getFlagsTypes($functionReflection, $funcCall, $scope) as $flagsType) { - if ($this->filterFunctionReturnTypeHelper->hasFlag('FILTER_THROW_ON_FAILURE', $flagsType)->no()) { + if ($this->filterFunctionReturnTypeHelper->hasThrowOnFailureFlag($flagsType, $scope->getPhpVersion())->no()) { continue; } diff --git a/tests/PHPStan/Analyser/data/scope-php-version-range-return-type-extensions.php b/tests/PHPStan/Analyser/data/scope-php-version-range-return-type-extensions.php index 7c084741d5f..c760a76b16a 100644 --- a/tests/PHPStan/Analyser/data/scope-php-version-range-return-type-extensions.php +++ b/tests/PHPStan/Analyser/data/scope-php-version-range-return-type-extensions.php @@ -34,3 +34,11 @@ function mbSubstituteCharacter(): void assertType('false', mb_substitute_character('foo')); assertType('false', mb_substitute_character(0x110000)); } + +function filterFlagGlobalRange(string $address): void +{ + assertType('non-falsy-string|false', filter_var($address, FILTER_VALIDATE_IP, FILTER_FLAG_GLOBAL_RANGE)); + assertType('non-falsy-string|false', filter_var($address, FILTER_VALIDATE_IP, FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE | FILTER_FLAG_GLOBAL_RANGE)); + assertType('non-falsy-string|null', filter_var($address, FILTER_VALIDATE_IP, FILTER_FLAG_GLOBAL_RANGE | FILTER_NULL_ON_FAILURE)); + assertType('array{ip: non-falsy-string|false|null}', filter_input_array(INPUT_GET, ['ip' => ['filter' => FILTER_VALIDATE_IP, 'flags' => FILTER_FLAG_GLOBAL_RANGE]])); +} diff --git a/tests/PHPStan/Rules/Exceptions/CatchWithUnthrownExceptionRuleConfigPhpTest.php b/tests/PHPStan/Rules/Exceptions/CatchWithUnthrownExceptionRuleConfigPhpTest.php index 85cbc4a08e4..b789dc13ca8 100644 --- a/tests/PHPStan/Rules/Exceptions/CatchWithUnthrownExceptionRuleConfigPhpTest.php +++ b/tests/PHPStan/Rules/Exceptions/CatchWithUnthrownExceptionRuleConfigPhpTest.php @@ -29,6 +29,10 @@ public function testThrowTypesInPhpVersionRange(): void 'Dead catch - ValueError is never thrown in the try block.', 19, ], + [ + 'Dead catch - Filter\\FilterFailedException is never thrown in the try block.', + 58, + ], ]); } diff --git a/tests/PHPStan/Rules/Exceptions/data/throw-type-php-versions.php b/tests/PHPStan/Rules/Exceptions/data/throw-type-php-versions.php index f3f83eedf7d..623c905c8a5 100644 --- a/tests/PHPStan/Rules/Exceptions/data/throw-type-php-versions.php +++ b/tests/PHPStan/Rules/Exceptions/data/throw-type-php-versions.php @@ -51,4 +51,13 @@ public function doStrSplit(string $s, int $length): void } } + public function doFilterVar(string $address): void + { + try { + $a = filter_var($address, FILTER_VALIDATE_IP, FILTER_FLAG_GLOBAL_RANGE); + } catch (\Filter\FilterFailedException $e) { + + } + } + } From 5aa4c722762dafe2eeff7b2ea2fd4988ef924e8b Mon Sep 17 00:00:00 2001 From: phpstan-bot Date: Thu, 22 Oct 2026 22:26:33 +0000 Subject: [PATCH 2/6] Treat ambiguous `FILTER_THROW_ON_FAILURE` bit as maybe instead of no When the PHP version range only maybe includes PHP 8.5 and the runtime FILTER_FLAG_GLOBAL_RANGE shares its bit, the set bit may mean either flag. Returning no made a catch of Filter\FilterFailedException around a real FILTER_THROW_ON_FAILURE call reported as dead. Co-Authored-By: Claude Opus 5.5 --- src/Type/Php/FilterFunctionReturnTypeHelper.php | 10 ++++++---- ...atchWithUnthrownExceptionRuleConfigPhpTest.php | 15 +++++++++++---- .../Exceptions/data/throw-type-php-versions.php | 9 +++++++++ 3 files changed, 26 insertions(+), 8 deletions(-) diff --git a/src/Type/Php/FilterFunctionReturnTypeHelper.php b/src/Type/Php/FilterFunctionReturnTypeHelper.php index cf774b476de..41d02e17a36 100644 --- a/src/Type/Php/FilterFunctionReturnTypeHelper.php +++ b/src/Type/Php/FilterFunctionReturnTypeHelper.php @@ -522,14 +522,16 @@ public function hasThrowOnFailureFlag(?Type $flagsType, PhpVersions $phpVersions return TrinaryLogic::createNo(); } + $hasFlag = $this->hasFlag('FILTER_THROW_ON_FAILURE', $flagsType); + // Flag values are resolved from the runtime. Before PHP 8.5, FILTER_FLAG_GLOBAL_RANGE used the bit - // that PHP 8.5 assigned to FILTER_THROW_ON_FAILURE, so the two cannot be told apart on such runtime - // unless the analysed code definitely runs on PHP 8.5+. + // that PHP 8.5 assigned to FILTER_THROW_ON_FAILURE, so on such runtime a set bit may mean either flag. + // When the analysed code definitely runs on PHP 8.5+, the bit can only mean FILTER_THROW_ON_FAILURE. if ($hasConstant->maybe() && $this->isThrowOnFailureFlagAmbiguous()) { - return TrinaryLogic::createNo(); + return $hasFlag->and(TrinaryLogic::createMaybe()); } - return $this->hasFlag('FILTER_THROW_ON_FAILURE', $flagsType); + return $hasFlag; } private function isThrowOnFailureFlagAmbiguous(): bool diff --git a/tests/PHPStan/Rules/Exceptions/CatchWithUnthrownExceptionRuleConfigPhpTest.php b/tests/PHPStan/Rules/Exceptions/CatchWithUnthrownExceptionRuleConfigPhpTest.php index b789dc13ca8..75623499d93 100644 --- a/tests/PHPStan/Rules/Exceptions/CatchWithUnthrownExceptionRuleConfigPhpTest.php +++ b/tests/PHPStan/Rules/Exceptions/CatchWithUnthrownExceptionRuleConfigPhpTest.php @@ -24,16 +24,23 @@ protected function getRule(): Rule public function testThrowTypesInPhpVersionRange(): void { - $this->analyse([__DIR__ . '/data/throw-type-php-versions.php'], [ + $errors = [ [ 'Dead catch - ValueError is never thrown in the try block.', 19, ], - [ + ]; + + if (PHP_VERSION_ID >= 80500) { + // Before PHP 8.5, FILTER_FLAG_GLOBAL_RANGE has the same runtime value as FILTER_THROW_ON_FAILURE on PHP 8.5, + // so it might throw Filter\FilterFailedException in a PHP version range that only maybe includes PHP 8.5. + $errors[] = [ 'Dead catch - Filter\\FilterFailedException is never thrown in the try block.', 58, - ], - ]); + ]; + } + + $this->analyse([__DIR__ . '/data/throw-type-php-versions.php'], $errors); } public static function getAdditionalConfigFiles(): array diff --git a/tests/PHPStan/Rules/Exceptions/data/throw-type-php-versions.php b/tests/PHPStan/Rules/Exceptions/data/throw-type-php-versions.php index 623c905c8a5..1ecf8296627 100644 --- a/tests/PHPStan/Rules/Exceptions/data/throw-type-php-versions.php +++ b/tests/PHPStan/Rules/Exceptions/data/throw-type-php-versions.php @@ -60,4 +60,13 @@ public function doFilterVar(string $address): void } } + public function doFilterVarThrowOnFailure(string $address): void + { + try { + $a = filter_var($address, FILTER_VALIDATE_IP, FILTER_THROW_ON_FAILURE); + } catch (\Filter\FilterFailedException $e) { + + } + } + } From 64ea62dcdf618bca8e9ae9a1872aa90548a863ad Mon Sep 17 00:00:00 2001 From: phpstan-bot Date: Fri, 23 Oct 2026 07:31:20 +0000 Subject: [PATCH 3/6] Explain when FILTER_THROW_ON_FAILURE and FILTER_FLAG_GLOBAL_RANGE share a value Co-Authored-By: Claude Opus 5.5 --- src/Type/Php/FilterFunctionReturnTypeHelper.php | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/src/Type/Php/FilterFunctionReturnTypeHelper.php b/src/Type/Php/FilterFunctionReturnTypeHelper.php index 41d02e17a36..c02b491abaa 100644 --- a/src/Type/Php/FilterFunctionReturnTypeHelper.php +++ b/src/Type/Php/FilterFunctionReturnTypeHelper.php @@ -534,6 +534,11 @@ public function hasThrowOnFailureFlag(?Type $flagsType, PhpVersions $phpVersions return $hasFlag; } + /** + * True when PHPStan runs on PHP < 8.5: FILTER_FLAG_GLOBAL_RANGE then has its runtime value 0x10000000, + * while FILTER_THROW_ON_FAILURE does not exist at runtime and is read from stubs with the PHP 8.5 value, + * which is also 0x10000000. On PHP 8.5+ runtime FILTER_FLAG_GLOBAL_RANGE is 0x20000000, so they differ. + */ private function isThrowOnFailureFlagAmbiguous(): bool { if (!$this->reflectionProvider->hasConstant(new Node\Name\FullyQualified('FILTER_FLAG_GLOBAL_RANGE'), null)) { From 32bb776c79e4bebba5d6970adb5063efca68aead Mon Sep 17 00:00:00 2001 From: phpstan-bot Date: Fri, 23 Oct 2026 07:52:23 +0000 Subject: [PATCH 4/6] Combine FILTER_THROW_ON_FAILURE constant availability with the flag check Co-Authored-By: Claude Opus 5.5 --- .../Php/FilterFunctionReturnTypeHelper.php | 31 ++----------------- 1 file changed, 3 insertions(+), 28 deletions(-) diff --git a/src/Type/Php/FilterFunctionReturnTypeHelper.php b/src/Type/Php/FilterFunctionReturnTypeHelper.php index c02b491abaa..31da7cb5e1e 100644 --- a/src/Type/Php/FilterFunctionReturnTypeHelper.php +++ b/src/Type/Php/FilterFunctionReturnTypeHelper.php @@ -517,35 +517,10 @@ public function hasFlag(string $flagName, ?Type $flagsType): TrinaryLogic public function hasThrowOnFailureFlag(?Type $flagsType, PhpVersions $phpVersions): TrinaryLogic { - $hasConstant = $phpVersions->hasFilterThrowOnFailureConstant(); - if ($hasConstant->no()) { - return TrinaryLogic::createNo(); - } - - $hasFlag = $this->hasFlag('FILTER_THROW_ON_FAILURE', $flagsType); - // Flag values are resolved from the runtime. Before PHP 8.5, FILTER_FLAG_GLOBAL_RANGE used the bit - // that PHP 8.5 assigned to FILTER_THROW_ON_FAILURE, so on such runtime a set bit may mean either flag. - // When the analysed code definitely runs on PHP 8.5+, the bit can only mean FILTER_THROW_ON_FAILURE. - if ($hasConstant->maybe() && $this->isThrowOnFailureFlagAmbiguous()) { - return $hasFlag->and(TrinaryLogic::createMaybe()); - } - - return $hasFlag; - } - - /** - * True when PHPStan runs on PHP < 8.5: FILTER_FLAG_GLOBAL_RANGE then has its runtime value 0x10000000, - * while FILTER_THROW_ON_FAILURE does not exist at runtime and is read from stubs with the PHP 8.5 value, - * which is also 0x10000000. On PHP 8.5+ runtime FILTER_FLAG_GLOBAL_RANGE is 0x20000000, so they differ. - */ - private function isThrowOnFailureFlagAmbiguous(): bool - { - if (!$this->reflectionProvider->hasConstant(new Node\Name\FullyQualified('FILTER_FLAG_GLOBAL_RANGE'), null)) { - return false; - } - - return $this->getConstant('FILTER_THROW_ON_FAILURE') === $this->getConstant('FILTER_FLAG_GLOBAL_RANGE'); + // that PHP 8.5 assigned to FILTER_THROW_ON_FAILURE, so a set bit only definitely means + // FILTER_THROW_ON_FAILURE when the analysed code definitely runs on PHP 8.5+. + return $phpVersions->hasFilterThrowOnFailureConstant()->and($this->hasFlag('FILTER_THROW_ON_FAILURE', $flagsType)); } private function getFlagsValue(Type $exprType): Type From 58d6613c8e26e26a21d7254d80c2c2f67d36ff76 Mon Sep 17 00:00:00 2001 From: phpstan-bot Date: Fri, 23 Oct 2026 12:11:59 +0000 Subject: [PATCH 5/6] Read the scope's PHP versions once in FilterFunctionsThrowTypeExtension Co-Authored-By: Claude Opus 5.5 --- src/Type/Php/FilterFunctionsThrowTypeExtension.php | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/Type/Php/FilterFunctionsThrowTypeExtension.php b/src/Type/Php/FilterFunctionsThrowTypeExtension.php index c08d71946ba..81069b5d588 100644 --- a/src/Type/Php/FilterFunctionsThrowTypeExtension.php +++ b/src/Type/Php/FilterFunctionsThrowTypeExtension.php @@ -37,15 +37,16 @@ public function getThrowTypeFromFunctionCall( Scope $scope, ): ?Type { + $phpVersions = $scope->getPhpVersion(); if ( - $scope->getPhpVersion()->hasFilterThrowOnFailureConstant()->no() + $phpVersions->hasFilterThrowOnFailureConstant()->no() || !$this->reflectionProvider->hasConstant(new Name\FullyQualified('FILTER_THROW_ON_FAILURE'), null) ) { return null; } foreach ($this->filterFunctionFlagsHelper->getFlagsTypes($functionReflection, $funcCall, $scope) as $flagsType) { - if ($this->filterFunctionReturnTypeHelper->hasThrowOnFailureFlag($flagsType, $scope->getPhpVersion())->no()) { + if ($this->filterFunctionReturnTypeHelper->hasThrowOnFailureFlag($flagsType, $phpVersions)->no()) { continue; } From 5349772d0b74674d31df69e6acd82a513507ad0a Mon Sep 17 00:00:00 2001 From: Markus Staab Date: Sat, 24 Oct 2026 09:48:05 +0200 Subject: [PATCH 6/6] separate php 8.2+ type assertions --- ...p-version-range-return-type-extensions-php82.php | 13 +++++++++++++ ...ope-php-version-range-return-type-extensions.php | 8 -------- .../CatchWithUnthrownExceptionRuleConfigPhpTest.php | 1 + 3 files changed, 14 insertions(+), 8 deletions(-) create mode 100644 tests/PHPStan/Analyser/data/scope-php-version-range-return-type-extensions-php82.php diff --git a/tests/PHPStan/Analyser/data/scope-php-version-range-return-type-extensions-php82.php b/tests/PHPStan/Analyser/data/scope-php-version-range-return-type-extensions-php82.php new file mode 100644 index 00000000000..ca677ae8a55 --- /dev/null +++ b/tests/PHPStan/Analyser/data/scope-php-version-range-return-type-extensions-php82.php @@ -0,0 +1,13 @@ += 8.2 + +namespace ScopePhpVersionRangeReturnTypeExtensions; + +use function PHPStan\Testing\assertType; + +function filterFlagGlobalRange(string $address): void +{ + assertType('non-falsy-string|false', filter_var($address, FILTER_VALIDATE_IP, FILTER_FLAG_GLOBAL_RANGE)); + assertType('non-falsy-string|false', filter_var($address, FILTER_VALIDATE_IP, FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE | FILTER_FLAG_GLOBAL_RANGE)); + assertType('non-falsy-string|null', filter_var($address, FILTER_VALIDATE_IP, FILTER_FLAG_GLOBAL_RANGE | FILTER_NULL_ON_FAILURE)); + assertType('array{ip: non-falsy-string|false|null}', filter_input_array(INPUT_GET, ['ip' => ['filter' => FILTER_VALIDATE_IP, 'flags' => FILTER_FLAG_GLOBAL_RANGE]])); +} diff --git a/tests/PHPStan/Analyser/data/scope-php-version-range-return-type-extensions.php b/tests/PHPStan/Analyser/data/scope-php-version-range-return-type-extensions.php index c760a76b16a..7c084741d5f 100644 --- a/tests/PHPStan/Analyser/data/scope-php-version-range-return-type-extensions.php +++ b/tests/PHPStan/Analyser/data/scope-php-version-range-return-type-extensions.php @@ -34,11 +34,3 @@ function mbSubstituteCharacter(): void assertType('false', mb_substitute_character('foo')); assertType('false', mb_substitute_character(0x110000)); } - -function filterFlagGlobalRange(string $address): void -{ - assertType('non-falsy-string|false', filter_var($address, FILTER_VALIDATE_IP, FILTER_FLAG_GLOBAL_RANGE)); - assertType('non-falsy-string|false', filter_var($address, FILTER_VALIDATE_IP, FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE | FILTER_FLAG_GLOBAL_RANGE)); - assertType('non-falsy-string|null', filter_var($address, FILTER_VALIDATE_IP, FILTER_FLAG_GLOBAL_RANGE | FILTER_NULL_ON_FAILURE)); - assertType('array{ip: non-falsy-string|false|null}', filter_input_array(INPUT_GET, ['ip' => ['filter' => FILTER_VALIDATE_IP, 'flags' => FILTER_FLAG_GLOBAL_RANGE]])); -} diff --git a/tests/PHPStan/Rules/Exceptions/CatchWithUnthrownExceptionRuleConfigPhpTest.php b/tests/PHPStan/Rules/Exceptions/CatchWithUnthrownExceptionRuleConfigPhpTest.php index 75623499d93..71bea413312 100644 --- a/tests/PHPStan/Rules/Exceptions/CatchWithUnthrownExceptionRuleConfigPhpTest.php +++ b/tests/PHPStan/Rules/Exceptions/CatchWithUnthrownExceptionRuleConfigPhpTest.php @@ -4,6 +4,7 @@ use PHPStan\Rules\Rule; use PHPStan\Testing\RuleTestCase; +use const PHP_VERSION_ID; /** * @extends RuleTestCase