Skip to content

Resolve PHP version checks in dynamic return type, throw type and type specifying extensions from Scope::getPhpVersion() - #6519

Closed
phpstan-bot wants to merge 1 commit into
phpstan:2.3.xfrom
phpstan-bot:create-pull-request/patch-nd6x5sn
Closed

phpstan-bot wants to merge 1 commit into
phpstan:2.3.xfrom
phpstan-bot:create-pull-request/patch-nd6x5sn

Conversation

@phpstan-bot

Copy link
Copy Markdown
Collaborator

Summary

Follow-up to #6496, #6497 and #6510: type extensions still read the analysed PHP version from a DI-injected PhpVersion, so they answered with a single version even inside a if (PHP_VERSION_ID >= 80300) { … } branch, and they could not express "this could go either way" when the analysed range spans a behavioural boundary (phpVersion min/max in NEON, or a composer require.php constraint).

This PR moves every extension that has a Scope over to Scope::getPhpVersion(). Because PhpVersions answers with TrinaryLogic, each site now handles three cases: Yes and No keep the previous per-version result, and Maybe yields the union of both versions' behaviour — so a PHP_VERSION_ID guard makes inference more precise while an unnarrowed version range stays conservative instead of silently picking one side.

Changes

src/Php/PhpVersions.php

Added the range-aware counterparts of the PhpVersion predicates the extensions needed: arrayFunctionsReturnNullWithNonArray(), hasDateTimeExceptions(), hasStricterRoundFunctions(), strSplitReturnsEmptyArray(), substrReturnFalseInsteadOfEmptyString(), highlightStringDoesNotReturnFalse(), throwsOnInvalidMbStringEncoding(), supportsPassNoneEncodings(), hasFilterThrowOnFailureConstant(), hasPDOSubclasses(), supportsPregUnmatchedAsNull(), supportsPregCaptureOnlyNamedGroups(), supportsAllUnicodeScalarCodePointsInMbSubstituteCharacter() and hasPhp8ReflectionReturnTypes().

arrayFunctionsReturnNullWithNonArray() (PHP < 8 returned null instead of throwing)

ArrayFlipFunctionReturnTypeExtension, ArrayValuesFunctionDynamicReturnTypeExtension, ArrayKeysFunctionDynamicReturnTypeExtension, ArraySliceFunctionReturnTypeExtension, ArraySpliceFunctionReturnTypeExtension, ArrayReverseFunctionReturnTypeExtension, ArraySearchFunctionDynamicReturnTypeExtension, ArrayFillKeysFunctionReturnTypeExtension, ArrayIntersectKeyFunctionReturnTypeExtension, ArrayChunkFunctionReturnTypeExtension and ArrayColumnHelper.

throwsValueErrorForInternalFunctions() / throwsTypeErrorForInternalFunctions()

ArrayChunkFunctionReturnTypeExtension, ArrayCombineFunctionReturnTypeExtension, ArrayFillFunctionReturnTypeExtension, ArrayFilterFunctionReturnTypeHelper, ArrayKeyExistsFunctionTypeSpecifyingExtension, ArrayColumnHelper, CountCharsFunctionDynamicReturnTypeExtension, HashFunctionsReturnTypeExtension, MbConvertEncodingFunctionReturnTypeExtension, MinMaxFunctionReturnTypeExtension, OpensslCipherFunctionsReturnTypeExtension, StrSplitFunctionReturnTypeExtension, TriggerErrorDynamicReturnTypeExtension, TriggerErrorFunctionThrowTypeExtension, VersionCompareFunctionDynamicReturnTypeExtension, VersionCompareFunctionDynamicThrowTypeExtension, MbSubstituteCharacterDynamicReturnTypeExtension, BcMathStringOrNullReturnTypeExtension and FilterFunctionReturnTypeHelper.

hasDateTimeExceptions() (PHP 8.3 DateException hierarchy)

DateTimeConstructorThrowTypeExtension, DateTimeModifyMethodThrowTypeExtension, DateTimeSubMethodThrowTypeExtension, DateTimeZoneConstructorThrowTypeExtension, DateIntervalConstructorThrowTypeExtension, DateIntervalCreateFromDateStringThrowTypeExtension, DateIntervalDynamicReturnTypeExtension and DateTimeModifyReturnTypeExtension. The throw type is now reported when the analysed range only may have DateTime exceptions, and the specific Date*Exception is only used when it is certain (otherwise the wider Exception covers both).

Per-function version quirks

RoundFunctionReturnTypeExtension (round()/ceil()/floor() — the PHP 8 and PHP 7 result are now computed separately and unioned), SubstrDynamicReturnTypeExtension, StrSplitFunctionReturnTypeExtension, HighlightStringDynamicReturnTypeExtension, MbStrlenFunctionReturnTypeExtension, MbFunctionsReturnTypeExtension + MbFunctionsReturnTypeExtensionTrait (the supported-encoding list is now cached per pass/none support instead of once), FilterVarThrowTypeExtension and PDOConnectReturnTypeExtension (the version check moved out of isStaticMethodSupported(), which has no Scope, into getTypeFromStaticMethodCall()).

Regex

RegexArrayShapeMatcher resolves PREG_UNMATCHED_AS_NULL support and the PHP 8.2 n modifier from the scope and threads both down its AST walk; RegexGroupParser::parseGroups() now takes the n-modifier support as an argument.

Reflection

AdapterReflectionEnumDynamicReturnTypeExtension, AdapterReflectionEnumCaseDynamicReturnTypeExtension and NativeReflectionEnumReturnDynamicReturnTypeExtension read their PHP 8 check from the scope.

Helper signatures

FilterFunctionReturnTypeHelper::getType()/getInputType() take a Scope (updated at all call sites in FilterVarDynamicReturnTypeExtension, FilterVarArrayDynamicReturnTypeExtension and FilterInputDynamicReturnTypeExtension), and FilterFunctionReturnTypeHelper::getConstant() returns null for a constant the runtime PHP does not define instead of throwing. ArrayColumnHelper::castToArrayKeyType() and MinMaxFunctionReturnTypeExtension::processArrayType() take the Scope they already had available on the caller.

Probed, deliberately left alone

  • BcMathNumberOperatorTypeSpecifyingExtension and BcMathNumberUnaryOperatorTypeSpecifyingExtension: the @api OperatorTypeSpecifyingExtension interface passes no Scope, so this would be a BC break.
  • ArrayUnpackingHelper (supportsArrayUnpackingWithStringKeys()): reached from AssignHandler while building array literals, not from a type extension — it is scope-machinery, not an extension.

No @api method signature changed: PhpVersions is final and only gained methods, RegexArrayShapeMatcher's public API is untouched (only its constructor, which @api explicitly does not cover), and the other touched helpers are not @api.

Root cause

The pattern is "an extension asks a single PhpVersion a yes/no question, even though the analysed code can tell it more". Two things go wrong:

  1. A if (PHP_VERSION_ID >= 8xxxx) guard narrows PHP_VERSION_ID in the scope, but the extension ignored that and answered for the globally configured version. Inside the guard the result was therefore wrong for one of the two branches.
  2. When the analysed PHP version is a range (NEON phpVersion: { min, max } or a composer require.php constraint), PhpVersion collapses it to one version, so the extension committed to one behaviour instead of returning the union of both.

The fix is the same everywhere: ask Scope::getPhpVersion(), which returns range-aware TrinaryLogic, and make Maybe produce the union of the two behaviours. In most cases the narrow result (never) is a subtype of the wide one (null/false), so ->yes() ? $narrow : $wide already is the union; where it is not (substr() returning false vs '', str_split('') returning [] vs [''], round(), mb_substitute_character()) the two branches are computed separately and combined with TypeCombinator::union().

Test

New type inference tests (all fail before the fix):

  • tests/PHPStan/Analyser/nsrt/scope-php-version-array-functions.php — the array_* family with a non-array argument, array_chunk() with a non-positive length, array_fill() with a negative count, array_column() with an array index, array_combine() and min()/max().
  • tests/PHPStan/Analyser/nsrt/scope-php-version-string-functions.phpsubstr(), str_split(), highlight_string(), count_chars(), hash(), round(), version_compare(), trigger_error(), bcdiv() and mb_substitute_character().
  • tests/PHPStan/Analyser/nsrt/scope-php-version-misc-functions.phpDateTime::modify(), DateInterval::createFromDateString(), filter_input(), mb_strlen(), mb_chr(), openssl_cipher_iv_length(), preg_match() with the n modifier, ClassReflection::getNativeReflection() and PDO::connect().

New throw type tests in CatchWithUnthrownExceptionRuleTest (both fail before the fix):

  • tests/PHPStan/Rules/Exceptions/data/dead-catch-php-versions.phpversion_compare() and trigger_error() only throw ValueError on PHP 8.
  • tests/PHPStan/Rules/Exceptions/data/dead-catch-date-php-versions.phpDateTime::sub() and DateTime::modify() only throw the Date*Exception types on PHP 8.3.

make tests, make phpstan and make cs-fix are all green. make name-collision fails on tests/PHPStan/Rules/Methods/data/static-call-pipe.php both with and without this change (the collision detector cannot parse the PHP 8.5 pipe operator on the PHP version used here).

Fixes phpstan/phpstan#15287

🤖 Generated with Claude Code

…e specifying extensions from `Scope::getPhpVersion()`

- Add the missing range-aware predicates to `PhpVersions` (`arrayFunctionsReturnNullWithNonArray()`, `hasDateTimeExceptions()`, `hasStricterRoundFunctions()`, `strSplitReturnsEmptyArray()`, `substrReturnFalseInsteadOfEmptyString()`, `highlightStringDoesNotReturnFalse()`, `throwsOnInvalidMbStringEncoding()`, `supportsPassNoneEncodings()`, `hasFilterThrowOnFailureConstant()`, `hasPDOSubclasses()`, `supportsPregUnmatchedAsNull()`, `supportsPregCaptureOnlyNamedGroups()`, `supportsAllUnicodeScalarCodePointsInMbSubstituteCharacter()`, `hasPhp8ReflectionReturnTypes()`).
- Drop the injected `PhpVersion` from 40+ extensions under `src/Type/Php/` and read `$scope->getPhpVersion()` instead. Every `Maybe` answer keeps the union of both versions' behaviour, so a narrowed `PHP_VERSION_ID` branch now yields the precise type while an unnarrowed version range stays conservative.
- `arrayFunctionsReturnNullWithNonArray()` family: `array_flip()`, `array_values()`, `array_keys()`, `array_slice()`, `array_splice()`, `array_reverse()`, `array_search()`, `array_fill_keys()`, `array_intersect_key()`, `array_chunk()` and `ArrayColumnHelper`.
- `throwsValueErrorForInternalFunctions()` / `throwsTypeErrorForInternalFunctions()` family: `array_chunk()`, `array_combine()`, `array_fill()`, `array_filter()`, `array_key_exists()`, `array_column()`, `count_chars()`, `hash*()`, `mb_convert_encoding()`, `min()`/`max()`, `openssl_cipher_*_length()`, `str_split()`/`mb_str_split()`, `trigger_error()` (return + throw type), `version_compare()` (return + throw type), `mb_substitute_character()`, `bcdiv()`/`bcmod()`/`bcpowmod()`/`bcsqrt()` and `filter_input()`.
- `hasDateTimeExceptions()` family: the `DateTime`/`DateTimeImmutable`/`DateInterval`/`DateTimeZone` constructor, `modify()`, `sub()` and `DateInterval::createFromDateString()` throw type extensions plus the matching return type extensions. Throw types are now reported when the version range only *may* have DateTime exceptions.
- Per-function version quirks: `round()`/`ceil()`/`floor()`, `substr()`/`mb_substr()`, `str_split()`, `highlight_string()`, `mb_strlen()`, `mb_internal_encoding()` and friends (via `MbFunctionsReturnTypeExtensionTrait`, whose encoding list is now cached per `pass`/`none` support), `filter_var()`/`filter_input()` and `PDO::connect()` (the version check moved from `isStaticMethodSupported()` into `getTypeFromStaticMethodCall()`).
- `RegexArrayShapeMatcher` passes the scope-resolved `PREG_UNMATCHED_AS_NULL` and `n`-modifier support down its AST walk instead of reading the injected `PhpVersion`; `RegexGroupParser::parseGroups()` takes the flag as an argument.
- The `ReflectionEnum`/`ReflectionEnumCase` adapter and `ClassReflection::getNativeReflection()` extensions resolve their PHP 8 check from the scope too.
- `FilterFunctionReturnTypeHelper::getType()`/`getInputType()` and `ArrayColumnHelper`/`ArrayFilterFunctionReturnTypeHelper` take (or already took) the `Scope`; `FilterFunctionReturnTypeHelper::getConstant()` no longer explodes on a constant missing from the runtime PHP.
- Probed and left alone: `BcMathNumberOperatorTypeSpecifyingExtension` and `BcMathNumberUnaryOperatorTypeSpecifyingExtension` (the `@api` `OperatorTypeSpecifyingExtension` interface has no `Scope`), and `ArrayUnpackingHelper` (called from `AssignHandler` while building array literals, not from an extension).

Closes phpstan/phpstan#15287

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@ondrejmirtes

Copy link
Copy Markdown
Member

This should be caught by a new rule so that we don't drift in the future.

@staabm staabm closed this Sep 22, 2026
@staabm
staabm deleted the create-pull-request/patch-nd6x5sn branch September 22, 2026 09:50
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.

Utilize $scope->getPhpVersion in return-type extensions

3 participants