Skip to content

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 - #6779

Open
phpstan-bot wants to merge 5 commits into
phpstan:2.3.xfrom
phpstan-bot:create-pull-request/patch-tt8irro
Open

phpstan-bot wants to merge 5 commits into
phpstan:2.3.xfrom
phpstan-bot:create-pull-request/patch-tt8irro

Conversation

@phpstan-bot

Copy link
Copy Markdown
Collaborator

Summary

When PHPStan runs on PHP < 8.5 and the analysed PHP version range only maybe reaches 8.5 (e.g. "php": "^8.4" in composer.json → int<80400, 80699>), FILTER_FLAG_GLOBAL_RANGE was read as FILTER_THROW_ON_FAILURE. filter_var() lost false from its return type and comparisons against false were reported as always true. This was a regression from #6551, which moved the version check to Scope::getPhpVersion(): for a range, the check became maybe, so hasFlag() ran.

Changes

  • src/Type/Php/FilterFunctionReturnTypeHelper.php: hasThrowOnFailureFlag() is now public. It returns no when the scope's PHP versions only maybe have FILTER_THROW_ON_FAILURE and the bit is ambiguous, which means the stubbed FILTER_THROW_ON_FAILURE value equals the runtime FILTER_FLAG_GLOBAL_RANGE value. If the scope is definitely PHP 8.5+ (e.g. inside if (PHP_VERSION_ID >= 80500)), the flag is still detected.
  • src/Type/Php/FilterFunctionsThrowTypeExtension.php: uses the same helper instead of a bare hasFlag('FILTER_THROW_ON_FAILURE'). This fixes the sibling bug where FILTER_FLAG_GLOBAL_RANGE was treated as throwing Filter\FilterFailedException, so a catch for it was not reported as dead.
  • Probed FilterVarRule ("Cannot use both FILTER_NULL_ON_FAILURE and FILTER_THROW_ON_FAILURE"). It is gated on the single configured PhpVersion, which stays below 8.5 for such ranges, so it was not affected in the reported scenario.

Root cause

PHPStan resolves internal constant values from the runtime. On PHP < 8.5, FILTER_FLAG_GLOBAL_RANGE is 0x10000000. PHP 8.5 moved it to 0x20000000 and gave 0x10000000 to FILTER_THROW_ON_FAILURE, which comes from stubs on older runtimes. A bitmask test therefore cannot tell the two flags apart on such a runtime. Before #6551, the version check for a range said "no". Afterwards it said "maybe", and the bitmask test ran anyway. Both consumers of the throw-on-failure check (the return type helper and the throw type extension) now go through one guarded method.

Test

  • tests/PHPStan/Analyser/data/scope-php-version-range-return-type-extensions.php (range 7.4–8.5): filter_var / filter_input_array calls with FILTER_FLAG_GLOBAL_RANGE, including the issue's combined flags, are expected to keep false (or null with FILTER_NULL_ON_FAILURE).
  • tests/PHPStan/Rules/Exceptions/data/throw-type-php-versions.php (range 8.0–8.5): catching Filter\FilterFailedException around filter_var(..., FILTER_FLAG_GLOBAL_RANGE) is reported as a dead catch.
  • Both fail without the fix on a PHP 8.4 runtime and pass with it. They also pass on PHP 8.5, where the constants don't collide. The existing nsrt assertion (int for FILTER_THROW_ON_FAILURE inside PHP_VERSION_ID >= 80500) still passes.

Fixes phpstan/phpstan#15481

Comment thread src/Type/Php/FilterFunctionReturnTypeHelper.php Outdated
Comment thread src/Type/Php/FilterFunctionReturnTypeHelper.php Outdated
Comment thread src/Type/Php/FilterFunctionReturnTypeHelper.php Outdated
@VincentLanglet
VincentLanglet force-pushed the create-pull-request/patch-tt8irro branch 2 times, most recently from 8103943 to ea26c30 Compare October 23, 2026 09:47
phpstan-bot and others added 4 commits October 23, 2026 12:55
…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.
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 <noreply@anthropic.com>
…re a value

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…heck

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@VincentLanglet
VincentLanglet force-pushed the create-pull-request/patch-tt8irro branch from ea26c30 to b30c6e1 Compare October 23, 2026 10:55

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()) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

move $scope->getPhpVersion() before the loop

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I moved the $scope->getPhpVersion() call before the loop and pushed the change as commit e6f5722f9. make tests passes (22562 tests, 106 skipped) and make phpstan reports no errors.

In FilterFunctionsThrowTypeExtension::getThrowTypeFromFunctionCall(), the PHP versions are now read once into $phpVersions at the top. The early-return check before the loop and the hasThrowOnFailureFlag() call inside it both use that variable, so getPhpVersion() is no longer called on every pass through the loop. Nothing else changed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Regression of #14397 in 2.3.0: FILTER_FLAG_GLOBAL_RANGE read as FILTER_THROW_ON_FAILURE on PHP 8.4

3 participants