Skip to content

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

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

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

Conversation

@phpstan-bot

Copy link
Copy Markdown
Collaborator

Summary

Return-type, throw-type and type-specifying extensions used to answer "which PHP version is being analysed?" with a DI-injected PhpVersion. That object only ever knows the single version configured in phpstan.neon (or the runtime one), so it silently ignores both PHP_VERSION_ID narrowing inside the analysed code and a configured phpVersion min/max range.

Following #6496, #6497 and #6510, which did this for rules, every extension that receives the call's Scope now resolves the analysed version from Scope::getPhpVersion(). A new build rule keeps them from drifting back.

Changes

PhpVersions (src/Php/PhpVersions.php)

  • Added the range-aware counterparts of the PhpVersion methods the extensions needed: arrayFunctionsReturnNullWithNonArray(), hasDateTimeExceptions(), hasFilterThrowOnFailureConstant(), hasPDOSubclasses(), hasStricterRoundFunctions(), highlightStringDoesNotReturnFalse(), strSplitReturnsEmptyArray(), substrReturnFalseInsteadOfEmptyString(), throwsOnInvalidMbStringEncoding(), supportsHhPrintfSpecifier(), supportsPassNoneEncodings(), supportsPregUnmatchedAsNull(), supportsPregCaptureOnlyNamedGroups(), isEmptyStringValidAliasForNoneInMbSubstituteCharacter(), isNullValidArgInMbSubstituteCharacter(), isNumericStringValidArgInMbSubstituteCharacter(), supportsAllUnicodeScalarCodePointsInMbSubstituteCharacter() and supportsNativeReflectionAdapterReturnTypes().
  • Added PhpVersions::pickType(TrinaryLogic $versionCheck, Type $ifYes, Type $ifNo). When the check is certain it returns the matching branch; when the analysed range spans both sides it returns their union, so an uncertain version never yields a type narrower than the truth.

Converted extensions (src/Type/Php/)

array_chunk, array_combine, array_fill, array_fill_keys, array_flip, array_intersect_key, array_keys, array_key_exists, array_reverse, array_search, array_slice, array_splice, array_values, bcdiv/bcmod/bcpowmod/bcsqrt, count_chars, DateInterval::__construct/createFromDateString, DateTime(Immutable)::__construct/modify/sub, DateTimeZone::__construct, filter_var/filter_input/filter_var_array/filter_input_array, hash*, highlight_string, mb_convert_encoding, the mb_* encoding family, mb_strlen, mb_substitute_character, min/max, openssl_cipher_*, PDO::connect, printf family, round/ceil/floor, str_split/mb_str_split, substr/mb_substr, trigger_error, version_compare.

Helpers threaded with PhpVersions

ArrayColumnHelper, ArrayFilterFunctionReturnTypeHelper, FilterFunctionReturnTypeHelper, MbFunctionsReturnTypeExtensionTrait, PrintfFormatParser, RegexArrayShapeMatcher and PHPStan\Type\Regex\RegexGroupParser.

  • RegexArrayShapeMatcher::matchPatternType() folds the analysed version's PREG_UNMATCHED_AS_NULL support into the parsed flag mask, so containsUnmatchedAsNull() no longer carries a version of its own.
  • MbFunctionsReturnTypeExtensionTrait now memoizes the encoding list both with and without the pass/none aliases, because the filtered variant depends on the analysed version.

Other call sites also fixed

  • src/Reflection/PHPStan/NativeReflectionEnumReturnDynamicReturnTypeExtension.php, src/Reflection/BetterReflection/Type/AdapterReflectionEnumDynamicReturnTypeExtension.php and src/Reflection/BetterReflection/Type/AdapterReflectionEnumCaseDynamicReturnTypeExtension.php - the same < PHP 8.0 gate, now read from the Scope.
  • src/Rules/Functions/FilterVarRule.php and src/Rules/Functions/Printf{,Array}ParametersRule.php / PrintfParameterTypeRule.php, which share the converted helpers.
  • PDOConnectReturnTypeExtension moved its version gate out of isStaticMethodSupported() - that method gets no Scope - into getTypeFromStaticMethodCall().

New build rule

build/PHPStan/Build/NoInjectedPhpVersionInScopeAwareExtensionRule.php reports any class implementing DynamicFunction/Method/StaticMethodReturnTypeExtension, DynamicFunction/Method/StaticMethodThrowTypeExtension or Function/Method/StaticMethodTypeSpecifyingExtension that takes PHPStan\Php\PhpVersion as a constructor parameter.

Probed and deliberately left alone

BcMathNumberOperatorTypeSpecifyingExtension and BcMathNumberUnaryOperatorTypeSpecifyingExtension implement OperatorTypeSpecifyingExtension, whose isOperatorSupported()/specifyType() receive no Scope at all, so they keep their injected PhpVersion and the build rule does not list that interface. The remaining PhpVersion injections in src/ belong to rules, reflection and the source-locator layer, which are outside this issue's scope.

Root cause

The pattern was "ask a DI-injected PhpVersion for a boolean, pick one of two types". PhpVersion collapses the analysed version to a single number, so the answer is a bool that cannot express "both are possible". Every extension inherited two defects from it:

  1. Scope-local knowledge was thrown away - inside if (PHP_VERSION_ID >= 80300) { ... } the extensions still answered with the globally configured version.
  2. A configured version range (phpVersion: min/max, or a composer.json require.php constraint) was flattened to its single resolved version.

The fix replaces the bool with PhpVersions' TrinaryLogic, and PhpVersions::pickType() turns the Maybe case into the union of both versions' results instead of silently picking one. Where a union is not meaningful (throw types, boolean predicates, encoding tables) the branch is chosen so that an uncertain version yields the wider, conservative answer.

MbSubstituteCharacterDynamicReturnTypeExtension needed more than a mechanical swap: it compares the argument against the set of valid code points, which itself moves with the version. It now builds two sets - the code points valid on every version in the analysed range, and those valid on at least one - and only returns a constant true/false when the argument lands inside the first or outside the second.

Test

  • tests/PHPStan/Analyser/nsrt/scope-php-version-return-type-extensions.php - a new type-inference test that asserts, for each converted family, that the two sides of an if (PHP_VERSION_ID >= X) { ... } else { ... } infer different types: the array functions with a non-array argument, array_chunk/array_fill/array_combine, substr, str_split, round, highlight_string, count_chars, hash, mb_substitute_character, trigger_error, version_compare, min, DateTime::modify, DateInterval::createFromDateString, PDO::connect, bcdiv, preg_match with PREG_UNMATCHED_AS_NULL and with the n modifier, and filter_var with FILTER_THROW_ON_FAILURE. Every one of the else branches fails on 2.3.x without this change.
  • tests/PHPStan/Build/NoInjectedPhpVersionInScopeAwareExtensionRuleTest.php with tests/PHPStan/Build/data/php-version-in-extension.php - covers an extension that injects PhpVersion (reported), one that reads the version from the Scope (not reported), and an OperatorTypeSpecifyingExtension that injects PhpVersion (not reported, it gets no Scope).
  • tests/PHPStan/Type/Php/PrintfFormatParserTest.php and the Printf*RuleTest/FilterVarRuleTest classes were updated for the changed constructor and parse() signatures.

Fixes phpstan/phpstan#15287

@staabm
staabm force-pushed the create-pull-request/patch-fs1nst7 branch from 72dc153 to e3d4151 Compare September 22, 2026 10:43
Comment thread src/Type/Php/ArrayChunkFunctionReturnTypeExtension.php Outdated
Comment thread src/Type/Php/PrintfFormatParser.php Outdated
@staabm
staabm force-pushed the create-pull-request/patch-fs1nst7 branch from 13aa62e to d2f4fe0 Compare September 22, 2026 13:20
@clxmstaab
clxmstaab force-pushed the create-pull-request/patch-fs1nst7 branch from d2f4fe0 to 6ab4e23 Compare September 22, 2026 13:44
@staabm
staabm force-pushed the create-pull-request/patch-fs1nst7 branch from 6ab4e23 to fba0257 Compare September 22, 2026 13:50
Comment thread tests/PHPStan/Type/Php/PrintfFormatParserTest.php Outdated
@clxmstaab
clxmstaab force-pushed the create-pull-request/patch-fs1nst7 branch from e47830b to 48b3d28 Compare September 22, 2026 14:31
return ($flags & PREG_UNMATCHED_AS_NULL) !== 0;
}

return ($flags & PREG_UNMATCHED_AS_NULL) !== 0 && (($flags & self::PREG_UNMATCHED_AS_NULL_ON_72_73) !== 0 || $this->phpVersion->supportsPregUnmatchedAsNull());

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.

why was || $this->phpVersion->supportsPregUnmatchedAsNull()) deleted?

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.

Pushed as 52b69ddf0.

Answering the question: it shouldn't have been deleted. The version half of the condition hadn't been dropped in behaviour — it had been folded into the flag mask up in matchPatternType(), which set self::PREG_UNMATCHED_AS_NULL_ON_72_73 when the analysed version supported the flag, so containsUnmatchedAsNull() only had to test the bit. That avoided threading PhpVersions through the private helpers, but it reused a public API constant — documented as "pass this in if the library supports emulating PREG_UNMATCHED_AS_NULL on PHP 7.2 and 7.3" — to carry an unrelated answer. Two different facts on one bit.

src/Type/Php/RegexArrayShapeMatcher.php:478 is back to the original shape, with PhpVersions threaded next to the $flags that buildArrayType(), createSubjectValueType(), isGroupOptional(), createGroupValueType() and getValueType() already pass around.

The fold also had a bug, which is why this isn't a pure revert. It used ->yes(), so a configured range spanning 7.4 (phpVersion: min 70200 / max 80500, or a composer require.php of >=7.2) resolved to "flag not supported" and inferred 'b' for a group that is genuinely null on every version from 7.4 up — a false negative on exactly the null the flag exists to produce. The restored condition uses !...->no(): only a range that certainly predates 7.4 falls back to the 7.2 behaviour, which is what the pre-PR code did for those versions anyway.

New test tests/PHPStan/Rules/Functions/CallToFunctionParametersRuleConfigPhpTest.php pins that with a min 70200 / max 80500 config — needsString($matches[2]) reports expects string, string|null given. Verified it fails before the fix (the range collapses and no error is reported at all) and passes after.

make tests (22167 tests, 97803 assertions, 105 skipped), make phpstan and make cs are green.

@SanderMuller SanderMuller left a comment

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.

Reviewed at head 52b69ddf0 against 2.3.x at 66f87da12. I am not the maintainer, so the design calls below are yours. The head moved three times while I was reading, so I re-checked every finding on the newest one.

The conversion itself is sound, and I could not make it produce a narrower type anywhere. Two things do need work.

1. version_compare() gains a null it cannot return

e2e/composer-max-version pins "php": "<=8.3", so the analysed range straddles 8.0. Reproduced locally on every head I saw:

$ cd e2e/composer-max-version && composer install --ignore-platform-reqs
$ ../../bin/phpstan analyze test.php --level=0
66f87da12   [OK] No errors
52b69ddf0   9   Expected type bool, actual: (bool|null)
            10  Expected type bool, actual: (bool|null)

In VersionCompareFunctionDynamicReturnTypeExtension the fallback return type is picked before the operator argument is read:

$operatorStrings = $scope->getType($args[2]->value)->getConstantStrings();
$counts[] = count($operatorStrings);
$returnType = $throwsValueError->yes()
    ? new BooleanType()
    : new BenevolentUnionType([new BooleanType(), new NullType()]);

throwsValueErrorForInternalFunctions() is Maybe over that range, so null goes in. But version_compare() only returns null for an invalid operator, and the call passes a constant '<'. The valid operators are already in $operatorStrings on the line above. Deciding the fallback after checking them keeps this bool.

2. Several extensions run the operation on the analysing host, which the Scope no longer matches

This is the one I would look at hardest. PhpVersion came from DI, so on CI it equalled the runtime and the two could not disagree. Scope::getPhpVersion() can now say 8.3 while the host is 8.2, and these extensions still probe the host.

DateTimeModifyMethodThrowTypeExtension guards with hasDateTimeExceptions()->no(), then calls $dateTime->modify($constantString->getValue()) inside a try. Inside the nsrt file's PHP_VERSION_ID >= 80300 branch the guard passes, so on an 8.2 host modify('nope') warns instead of throwing. phpunit.xml sets failOnWarning="true", so every lane below 8.3 fails:

DateTime::modify(): Failed to parse time string (nope) at position 0 (n)
Triggered by: NodeScopeResolverTest::testFile#nsrt/scope-php-version-return-type-extensions.php

RegexGroupParser::parseGroups() validates the pattern with Strings::match('', $regex), which is the host PCRE. The n modifier only exists from 8.2, so on older hosts the pattern is rejected and pregCaptureOnlyNamedGroups() gets no shape at all:

Line 142: Expected: array{0: non-falsy-string, name: 'a', 1: 'a'}   Actual: array<string>
Line 146: Expected: array{0: non-falsy-string, name: 'a', 1: 'a', 2: 'b'}   Actual: array<string>

This is the same shape as the PDO::connect() block you already pulled out: a PHP_VERSION_ID guard moves the analysed version, not the host. Wherever an extension executes the real function to learn the answer, a config test pinning phpVersion is the only form that works on every runner. str_split, mb_str_split and preg_match are in the same family and are worth a look.

I could not run 8.2 here, so these two come from the CI output plus the two code sites above.

The conversion does not over-reach

I dumped 17 expressions across five configurations on base and on 52b69ddf0: fixed 70200, 70400 and 80500, and the ranges 70200-80500 and 70400-80500.

At a fixed version, base and PR agree on every expression. Nothing moved.

At a range, four expressions widen and none narrow.

expression base, 70400-80500 PR, same range
substr('', 5) false ''|false
str_split('', 1) array{''} array{}|array{''}
mb_substitute_character(null) false bool
mb_substitute_character(0) false bool

Those base answers are what base gives at a fixed 70400, so a range used to collapse to its lower end. Each PR answer is the union of the two endpoints, which is the point of the change.

What else I checked

  • The nsrt file is a real regression test. With src/ reset to 2.3.x, 26 of its assertions fail.
  • No BC break on RegexArrayShapeMatcher. The class carries @api, its constructor does not, and no public method signature moved.
  • Nothing here is turbo-shadowed. No changed file carries #[ShadowedByTurboExtension].
  • Green on the head here: the build rule test, the throw-type config tests, PrintfFormatParserTest. phpcs passes over every touched PHP file.

Design calls for you

  • One question on the PREG_UNMATCHED_AS_NULL fix in 52b69ddf0. My probe shows a range of 70200-80500 now inferring 'b'|null where base inferred 2?: 'b'. The comment argues that assuming the documented behaviour avoids hiding a real null, which I follow for 7.4 and later. On 7.2 and 7.3 the group is '' at runtime, so that half now gets a null it cannot see. Both answers pick an endpoint rather than the union, so this is a trade between two false directions.
  • PDOConnectReturnTypeExtension keeps its injected PhpVersion behind a @phpstan-ignore. isStaticMethodSupported() gates on the configured version again, so a configured range straddling 8.4 gets the collapsed answer. Narrow enough that leaving it may be right.
  • Return-type extensions still have no test at a straddling range. Every assertion in the nsrt file sits inside if (PHP_VERSION_ID >= X). That makes the version certain. 52b69ddf0 adds a phpVersion: 70200-80500 test, but it is a rule test covering preg_match. The four widenings above are what a return-type one would pin, and finding 1 is what it would have caught.
  • The rule reads only the extension class's own constructor. A helper that injects PhpVersion and is used by an extension slips past it. PrintfFormatParser was that shape until #6536.
  • The description is stale. It still documents PhpVersions::pickType(), which 2c653a5e6 removed at your request, and the PrintfFormatParser threading that belongs to #6536. The title and body become the squash commit.

Performance

Measured on 49a0bb87a, one head back. I put a counter in MutatingScope::getPhpVersion() and ran a single-process self-analysis of this repo on both branches:

base 66f87da12 PR 49a0bb87a
getPhpVersion() calls 214,556 220,684

The conversion adds 6,128 calls, 2.9% more of something the analysis already does 214,556 times.

Wall-clock says less than that. Three interleaved batches of self-analysis put the PR median 2% to 7% above base. The within-arm spread is 9s to 26s CPU, and the sign of the paired delta flips once I alternate which arm runs first. For the conversion to explain even a 5% shift, each added call would have to cost about 1.2ms. It plainly does not. I read the gap as machine noise, but my machine is too noisy to prove it.

CI

Not yours: Test (PHP 8.5), Test (PHP 7.4) and PHPStan (8.1, windows-latest) are red on the 2.3.x tip. The whole Compile Turbo Extension and Turbo Extension * cluster, PHPStan (7.4, *) and Mutation Testing are green on the tip. They are red on #6488 as well, so they are not this change.

Yours: E2E tests (composer-max-version) is finding 1. Tests with old PHPUnit (8.0, 8.1) and Tests PHPUnit 11.x (8.2) are finding 2, and both are green on the tip and on #6488.

Verdict

Finding 1 is a real behaviour change and the e2e fixture already catches it. Finding 2 is worth a broader pass, because the host and the analysed version can now disagree anywhere an extension calls the real function. The conversion itself measures clean and leaves fixed-version inference untouched.

@phpstan-bot

Copy link
Copy Markdown
Collaborator Author

Pushed four commits.

Finding 1 — version_compare() gains a null it cannot return

bbaabb974. The fallback return type was picked before the operator argument was read, so an uncertain throwsValueErrorForInternalFunctions() put null into a call that passes a constant '<'. It's now decided after the operator strings are known (VersionCompareFunctionDynamicReturnTypeExtension.php:77): null survives only when the operator is non-constant or is genuinely not in VALID_OPERATORS.

The "might be an invalid operator" test moved to a shared mightBeInvalidOperator()VersionCompareFunctionDynamicThrowTypeExtension already had the same loop open-coded and now calls it, so the two can't drift.

Confirmed on the fixture you named: e2e/composer-max-version is [OK] No errors again. Note this is also a fix at a fixed version — at 7.4 the old code gave bool|null for version_compare($a, $b, '<') too.

Finding 2 — extensions probing the analysing host

b4c318b91 — DateTime. DateTimeModifyMethodThrowTypeExtension and DateIntervalCreateFromDateStringThrowTypeExtension both ran the real function and treated "didn't throw" as "can't fail". That only holds when the host is 8.3+. Both now suppress the warning and read the return value, which is the failure signal on every version — the same shape DateTimeModifyReturnTypeExtension and DateIntervalDynamicReturnTypeExtension already use. The hasDateTimeExceptions() guard stays as it is; it's the detection that was host-bound, not the gate.

94f60a918 — regex. RegexGroupParser compiles the pattern in-process to validate it, and n only exists in PCRE from PHP 8.2, so a pattern targeting 8.2 lost its shape on an older host before supportsPregCaptureOnlyNamedGroups() was ever consulted. The validation compile now strips modifiers the host may not know (MODIFIERS_NOT_COMPILABLE_ON_EVERY_RUNTIME). PHPStan implements n itself via $captureOnlyNamed, so dropping it for validation changes no shape and makes that step host-independent.

I swept the other converted extensions for the same pattern. str_split('')'s 8.2 difference and substr()'s 7/8 difference are already computed without the host, and RegexArrayShapeMatcher never runs preg_match. What remains host-bound — hash_algos(), mb_list_encodings(), openssl_get_cipher_methods(), mb_internal_encoding() — never consulted a PhpVersion at all, before or after, so this PR doesn't move them; they're a separate problem.

478ebe4cc — the straddling-range return-type test you asked for

ScopePhpVersionRangeReturnTypeExtensionsTest with phpVersion: min 70400 / max 80500. It pins exactly the four widenings from your table (substr, str_split(''), mb_substitute_character with null and with 0) plus the version_compare case. Verified it fails on the pre-fix code for the right reason — two assertions, bool vs (bool|null) — and passes after.

Not changed

The PREG_UNMATCHED_AS_NULL trade on 7.2/7.3, PDOConnectReturnTypeExtension's injected PhpVersion, and the rule reading only the extension class's own constructor are design calls; I left them as they are. The description is stale as you say — pickType and the PrintfFormatParser threading are both gone from the branch; I can't edit it from here (gh reports pull requests disabled for this repo).

make tests 22176 tests / 97811 assertions / 105 skipped, make phpstan no errors over 2740 files, phpcs clean on every touched file.

@SanderMuller

Copy link
Copy Markdown
Contributor

Checked 478ebe4cc. Both fixes hold. e2e/composer-max-version is [OK] No errors again. The new range test drops to 2 failures for the right reason when src/ is reset to 52b69ddf0.

A correction to my CI section. I wrote that PHPStan (7.4, *) was not yours because #6488 was red there too. That was wrong, and the log now shows it:

PHPStan\Type\Php\PDOConnectReturnTypeExtension ... must not inject PHPStan\Php\PhpVersion
ignore.unmatchedIdentifier (non-ignorable)

A class node starts on its attribute line, not on final class. php-parser gives line 3 for an attribute on 3 and final class on 4. So the @phpstan-ignore above #[AutowiredService] matches on 8.x. The downgraded 7.4 build strips the attribute, moves the class down a line, and the ignore misses. Moving the comment down would break the other lane, so a path entry in build/phpstan.neon ignoreErrors is the one that works on both.

Integration tests (ubuntu-latest) is also red, green on the tip and on 52b69ddf0. Its run had not finished when I looked, so I have no log for it yet.

Two small things:

  • The version_compare fix also changes fixed-version inference. At a fixed 7.4, base gives bool|null for version_compare($a, $b, '<') and this gives bool. Worth a line in the description, since it can move someone's baseline.
  • In scope-php-version-range-return-type-extensions.php, version_compare($a, $b, $s) uses an undefined $s. Both forms infer (bool|null), so the assertion is right by luck rather than by construction. string $s in the signature makes it test what the comment says.

staabm and others added 15 commits September 22, 2026 19:41
…ying extensions from `Scope::getPhpVersion()`

* Add the range-aware counterparts of the `PhpVersion` methods the extensions
  needed to `PhpVersions`: `arrayFunctionsReturnNullWithNonArray()`,
  `hasDateTimeExceptions()`, `hasFilterThrowOnFailureConstant()`,
  `hasPDOSubclasses()`, `hasStricterRoundFunctions()`,
  `highlightStringDoesNotReturnFalse()`, `strSplitReturnsEmptyArray()`,
  `substrReturnFalseInsteadOfEmptyString()`, `throwsOnInvalidMbStringEncoding()`,
  `supportsHhPrintfSpecifier()`, `supportsPassNoneEncodings()`,
  `supportsPregUnmatchedAsNull()`, `supportsPregCaptureOnlyNamedGroups()`,
  the three `mb_substitute_character` predicates and
  `supportsNativeReflectionAdapterReturnTypes()`.
* Add `PhpVersions::pickType()`, which returns the branch matching a certain
  version check and the union of both branches when the analysed version range
  spans them, so a `Maybe` answer never produces a type narrower than the truth.
* Convert every `Dynamic*ReturnTypeExtension`, `Dynamic*ThrowTypeExtension` and
  `*TypeSpecifyingExtension` in `src/Type/Php/` that injected `PhpVersion` (array
  functions, `substr`, `str_split`, `round`, `highlight_string`, `count_chars`,
  `hash`, `mb_*`, `min`/`max`, `openssl_cipher_*`, `printf`, `trigger_error`,
  `version_compare`, `filter_*`, `bcmath`, `PDO::connect`, the `DateTime`/
  `DateInterval`/`DateTimeZone` family) to read the version from the call's Scope.
* Thread `PhpVersions` through the helpers those extensions share:
  `ArrayColumnHelper`, `ArrayFilterFunctionReturnTypeHelper`,
  `FilterFunctionReturnTypeHelper`, `MbFunctionsReturnTypeExtensionTrait`,
  `PrintfFormatParser`, `RegexArrayShapeMatcher` and `RegexGroupParser`.
* `RegexArrayShapeMatcher` now folds the analysed version's
  `PREG_UNMATCHED_AS_NULL` support into the parsed flags, so
  `containsUnmatchedAsNull()` no longer needs a version of its own.
* `PDOConnectReturnTypeExtension` moved its version gate out of
  `isStaticMethodSupported()` (which gets no Scope) into
  `getTypeFromStaticMethodCall()`.
* The same treatment for the three BetterReflection adapter return-type
  extensions in `src/Reflection/`, and for `FilterVarRule` and the three
  `Printf*Rule`s that share the converted helpers.
* Add `PHPStan\Build\NoInjectedPhpVersionInScopeAwareExtensionRule` so extensions
  implementing a Scope-receiving interface cannot drift back to injecting
  `PhpVersion`. `OperatorTypeSpecifyingExtension` is deliberately not covered -
  its interface hands over no Scope, so `BcMathNumber*OperatorTypeSpecifyingExtension`
  keep their injected `PhpVersion`.
…nsions from Scope::getPhpVersion()

Both DynamicFunctionThrowTypeExtensions still asked a DI-injected PhpVersion
whether the function throws, so they ignored PHP_VERSION_ID narrowing in the
analysed code and flattened a configured phpVersion range to a single version.
They now read the version from the call's Scope and only rule the throw out
when the analysed range certainly does not throw.

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

The rule only knew nine hardcoded extension interfaces, so parameter-out,
parameter-closure, expression-type-resolver, ignore-error and restricted-usage
extensions could keep injecting PhpVersion unnoticed - and any extension
interface added later would have to be added here by hand.

It now reports every class that implements an interface marked with
#[ExtensionInterface] declaring a method that takes the public Scope.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Reverts the reflection-based discovery: the rule again holds a constant with
the extension interfaces whose methods take the public Scope, now covering all
of them - the return-type, throw-type and type-specifying interfaces plus the
parameter-out, parameter-closure-type, parameter-closure-this, restricted-usage,
expression-type-resolver, ignore-error and collector ones.

ExprHandler/StmtHandler (MutatingScope) and OperatorTypeSpecifyingExtension
(no Scope at all) stay out, as documented in the class docblock.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…:pickType()

The extensions converted to Scope::getPhpVersion() went through a
PhpVersions::pickType() helper that mapped a TrinaryLogic to one of two types.
Inline the branching at every call site and drop the helper.

Behaviour is unchanged: where the union of both branches collapses to one of
them (never|false is false, null|never is null, true|bool is bool), the plain
if() returns that branch on Maybe; where both remain possible - substr()'s
false vs empty string, str_split('')'s [] vs [''] and mb_substitute_character()
with null - both are still added to the result.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
PrintfFormatParser::parse() reading the analysed PHP version from the call
site is a self-contained change with its own test, so it is reviewed
separately. The parser keeps its injected PhpVersion here.

PrintfFunctionThrowTypeExtension still resolves its own
throwsValueErrorForInternalFunctions() check from Scope::getPhpVersion(),
which is what this pull request is about.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The printf format parser change was split off into phpstan#6536, which has since
been merged into the base branch. Reverting it on this branch therefore
undid already-merged work instead of separating it, so the parser keeps
parse(string $format, PhpVersions $phpVersions) and the rules keep passing
Scope::getPhpVersion().

PrintfFunctionThrowTypeExtension keeps this pull request's own change:
its throwsValueErrorForInternalFunctions() check resolved from the Scope.

This reverts commit fba0257.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…rialize() extensions from Scope::getPhpVersion()

These four extensions landed on the base branch after this pull request
started, so the build rule added here reports them. They follow the same
conversion as the rest: the throw type extensions return VoidType only when
the analysed range certainly does not throw, and get_class() without
arguments unions the PHP 8 result with false when the range spans PHP 7.

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

The data file's class declarations moved by two lines, so the rule reports
lines 25, 67 and 86.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…atchedAsNull()

The version half of the PREG_UNMATCHED_AS_NULL condition had been folded
into the flag mask in matchPatternType(), which reused the public
PREG_UNMATCHED_AS_NULL_ON_72_73 constant - documented for libraries that
emulate the flag on 7.2/7.3 - to carry an unrelated answer.

Thread PhpVersions down to containsUnmatchedAsNull() instead, alongside
the $flags it already receives, and restore the original condition. An
analysed range that still reaches PHP 7.4 now keeps the documented
behaviour rather than falling back to the 7.2 one, because inferring a
string where the group can be null would hide a real null.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
version_compare() returns null only for an invalid operator, so the fallback
return type has to be decided after the operator argument has been read, not
before. Picking it up front meant an uncertain throwsValueErrorForInternalFunctions()
added a null the call cannot produce - e2e/composer-max-version pins "php": "<=8.3",
whose range straddles PHP 8.0, and version_compare(PHP_VERSION, '7.0.0', '<')
started inferring bool|null there.

The "the operator might be invalid" test is now shared with
VersionCompareFunctionDynamicThrowTypeExtension, which already had it.

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

Both extensions probe the string by running the real function in the analyser
process. DateTime::modify() and DateInterval::createFromDateString() only throw
since PHP 8.3; before that they emit a warning and return false.

While the version came from DI it always matched the runtime, so catching
Throwable was enough. Now that it comes from Scope::getPhpVersion(), the
analysed version can be 8.3+ while this process runs on an older one: the
guard passes, the call warns instead of throwing and the extension concludes
the call cannot fail. Suppress the warning and read the return value instead,
the way the matching return-type extensions already do.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
RegexGroupParser validates a pattern by compiling it in the analyser process,
but decides captureOnlyNamed from the analysed version. The n modifier only
exists in the PCRE bundled with PHP 8.2+, so a pattern targeting 8.2 fails
validation on an older host and loses its array shape entirely, long before
the analysed version is consulted.

PHPStan implements the modifier itself, so removing it for the validation
compile makes that step give the same answer on every host.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The nsrt file only covers narrowing through PHP_VERSION_ID, which makes the
analysed version certain. Pin the uncertain case too: with phpVersion
min 70400 / max 80500 every version check inside the converted extensions is
Maybe, and the inferred type has to stay wide enough for both ends.

Covers the four expressions that widen at a range - substr(), str_split(''),
mb_substitute_character() with null and with 0 - and the version_compare()
regression, which fails here without the previous fix.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@staabm
staabm force-pushed the create-pull-request/patch-fs1nst7 branch from 478ebe4 to b2db476 Compare September 22, 2026 17:41
Comment thread phpstan-baseline.neon
Comment on lines +1551 to +1556
-
rawMessage: 'PHPStan\Type\Php\PDOConnectReturnTypeExtension implements PHPStan\Type\DynamicStaticMethodReturnTypeExtension and must not inject PHPStan\Php\PhpVersion - read the analysed PHP version from Scope::getPhpVersion() instead.'
identifier: phpstanBuild.phpVersionInExtension
count: 1
path: src/Type/Php/PDOConnectReturnTypeExtension.php

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.

PDOConnectReturnTypeExtension requires php-src builtin classes, only available in PHP 8.4+ (its functionality does not depend on a configured php version but runtime capabilities)

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've made the change and committed it. make phpstan is clean and the targeted tests pass; the full make tests run is still going, and I'll push once it finishes.

Summary of what I did for the review comment:

PDOConnectReturnTypeExtension no longer injects PhpVersion at all — it gates on the reflection capability your comment describes, so the baseline entry is gone rather than papered over.

PDO::connect() and PDO\Mysql/Firebird/Dblib/Odbc/Pgsql/Sqlite are php-src builtins that only exist on 8.4+, and reflection does not narrow along with a PHP_VERSION_ID check — which is exactly why Scope::getPhpVersion() can't answer here. Two checks replace hasPDOSubclasses():

  • isStaticMethodSupported() matches only connect() declared by the builtin PDO. That method resolves only when reflection has the 8.4 PDO, so it subsumes the version gate. Verified: at phpVersion: 80300 the call is Call to an undefined static method PDO::connect() and the extension is never consulted.
  • each subclass has to be a class ReflectionProvider actually knows before it is named in the return type.

PhpVersions::hasPDOSubclasses() lost its only caller and is removed. PhpVersion::hasPDOSubclasses() stays — it's @api and released.

One behaviour change fell out, in the right direction. Restricting to the declaring class stops the extension overriding a connect() that a PDO subclass declares itself — that method is the subclass's own. Before, at 8.4, MyPdo::connect('sqlite:foo') returning static was reported as PDO\Sqlite. I pinned it in tests/PHPStan/Analyser/nsrt/pdo-connect-php84.php (the file is // lint >= 8.4, so it only runs on 8.4+ runners); the assertion fails with Actual: PDO\Sqlite on the pre-change source and passes after. Everything below 8.4 infers exactly what it did before — I checked a user subclass at phpVersion: 80300 on both sides.

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