Skip to content

Honour the composer.json require.php constraint in Scope::getPhpVersion() - #6476

Merged
staabm merged 3 commits into
phpstan:2.3.xfrom
phpstan-bot:create-pull-request/patch-5oo2wq4
Sep 19, 2026
Merged

staabm merged 3 commits into
phpstan:2.3.xfrom
phpstan-bot:create-pull-request/patch-5oo2wq4

Conversation

@phpstan-bot

Copy link
Copy Markdown
Collaborator

Summary

On a project whose composer.json requires e.g. "php": "^7.4 || ^8.0", PHPStan reported Catch variable $e is never read. even though non-capturing catches only exist since PHP 8.0 and the variable therefore cannot be dropped.

UnusedVariableRule already guards that report with $scope->getPhpVersion()->supportsNoncapturingCatches()->yes(), but Scope::getPhpVersion() did not know about the composer constraint: it only consulted a scope-narrowed PHP_VERSION_ID and the NEON phpVersion: {min, max} range, and otherwise returned the single analysed PHP version (the runtime version, 8.x on CI). ConstantResolver on the other hand does narrow PHP_VERSION_ID from require.php, so the two disagreed.

The fix makes Scope::getPhpVersion() fall back to exactly the PHP_VERSION_ID type that ConstantResolver produces, so the scope's PHP version and the PHP_VERSION_ID constant can no longer contradict each other.

Changes

  • src/Analyser/MutatingScope.php
    • getPhpVersion(): when neither the scope nor the NEON phpVersion range provides information, fall back to ConstantResolver::resolvePredefinedConstant('PHP_VERSION_ID'), which folds in the composer.json require.php range.
    • Extracted the inline "this range spans everything PHPStan can analyse, so it carries no information" check into a private isOverallPhpVersionRange() and reused it for both the scope-narrowed and the fallback type. This keeps the pre-existing behaviour for projects without any constraint (fall back to the single analysed version).

Every PhpVersions consumer was probed against a composer-derived range. All of the following were broken in the same way and are fixed by this single change; each got its own regression test:

Consumer PhpVersions method Test
Rules/DeadCode/UnusedVariableRule (the reported bug) supportsNoncapturingCatches() UnusedVariableRuleComposerPhpVersionRangeTest
Rules/Exceptions/NoncapturingCatchRule supportsNoncapturingCatches() NoncapturingCatchRuleComposerPhpVersionRangeTest
Rules/FunctionCallParametersCheck supportsNamedArguments() CallToFunctionParametersRuleComposerPhpVersionRangeTest
Rules/FunctionCallParametersCheck supportsNamedArgumentAfterUnpackedArgument() CallToFunctionParametersRuleComposerPhp80RangeTest
Rules/Methods/FinalPrivateMethodRule producesWarningForFinalPrivateMethods() FinalPrivateMethodRuleComposerPhpVersionRangeTest
Rules/TooWideTypehints/TooWideTypeCheck supportsTrueAndFalseStandaloneType() TooWideFunctionReturnTypehintRuleComposerPhpVersionRangeTest
Type/Php/IniGetReturnTypeExtension supportsMaxMemoryLimit() ComposerPhpVersionRangeIniGetTest
MutatingScope::getFunctionType() / getArrayType() (variadic parameter is a list before named arguments exist) supportsNamedArguments() ComposerPhpVersionRangeVariadicTest

Probed and found already correct (no test kept): BetterReflectionProvider::getConstant() only uses the version type as a cache key, and the NEON phpVersion: {min, max} path already worked - it is left untouched and still takes precedence over the composer constraint.

New composer fixtures under tests/PHPStan/Analyser/data/: composer-require-php-7-only (^7.4), composer-require-php-7-and-8 (^7.4 || ^8.0), composer-require-php-8-0 (^8.0) and composer-require-php-8-5 (^8.5); tests opt in via the existing getComposerAutoloaderProjectPaths() hook.

Root cause

Two independent code paths answered the question "which PHP version(s) is this project analysed against?", and only one of them knew about composer:

  • ConstantResolver::resolvePredefinedConstant('PHP_VERSION_ID') goes through ConfiguredPhpVersionRangeHelper, which returns the NEON phpVersion range or the composer.json require.php range.
  • MutatingScope::getPhpVersion() read the scope-narrowed PHP_VERSION_ID (only set inside if (PHP_VERSION_ID …) conditions), then the NEON range, then gave up and returned the single analysed PhpVersion.

So on a ^7.4 || ^8.0 project PHP_VERSION_ID was int<70400, 80699> while Scope::getPhpVersion() claimed 80425. Every PhpVersions query then answered yes/no where it should have answered maybe, which is why version-gated rules behaved as if the project were PHP 8.4 only. Routing the fallback through the very same ConstantResolver entry point removes the second source of truth.

Test

  • tests/PHPStan/Rules/DeadCode/UnusedVariableRuleComposerPhpVersionRangeTest — the reported bug: no Catch variable $e is never read. when composer allows PHP 7. Fails before the fix with exactly the error from the issue.
  • tests/PHPStan/Analyser/ScopePhpVersionComposerRangeTest — pins Scope::getPhpVersion() to int<70400, 80699> for a ^7.4 || ^8.0 project, and checks that an explicit if (PHP_VERSION_ID < 80000) return; still narrows it further to int<80000, 80699>.
  • The seven analogous-case tests listed in the table above. Each was verified to fail before the fix and pass after it.
  • Full suite (make tests), self-analysis (make phpstan) and make cs-fix are green. make name-collision fails on tests/PHPStan/Rules/Methods/data/static-call-pipe.php both with and without this change (pre-existing, unrelated to this PR).

Fixes phpstan/phpstan#15270

…rsion()`

* `MutatingScope::getPhpVersion()` only looked at a scope-narrowed `PHP_VERSION_ID`
  and at the NEON `phpVersion` min/max range. Without either it fell back to the
  single analysed `PhpVersion`, so the composer.json `require.php` range - which
  `ConstantResolver` already uses to narrow `PHP_VERSION_ID` - was ignored.
* Fall back to `ConstantResolver::resolvePredefinedConstant('PHP_VERSION_ID')` so
  `Scope::getPhpVersion()` can no longer contradict the `PHP_VERSION_ID` constant.
* Extracted the inline "overall PHP version range" detection into
  `MutatingScope::isOverallPhpVersionRange()` and reused it for both lookups.
* Fixes the whole family of `PhpVersions` consumers at once, each covered by its
  own regression test: `UnusedVariableRule` (`catch.unusedVariable`),
  `NoncapturingCatchRule`, `FunctionCallParametersCheck` (named arguments and
  named-argument-after-unpacked-argument), `FinalPrivateMethodRule`,
  `TooWideTypeCheck` (native `true`/`false` suggestions),
  `IniGetReturnTypeExtension` (`max_memory_limit`) and variadic parameter types
  in `MutatingScope::getFunctionType()`.

@staabm staabm 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.

tests requiring a composer.json should be moved into e2e

The regression tests for `Scope::getPhpVersion()` honouring the composer.json
`require.php` constraint each needed a composer.json of their own, which they
got by pointing `getComposerAutoloaderProjectPaths()` at fixture directories
under `tests/`. Tests that need a composer.json belong in e2e, where the whole
`composer install` + `bin/phpstan` round trip is exercised.

Four e2e projects, one per `require.php` constraint, cover the same ground:

* `composer-php-version-range-7-and-8` (`^7.4 || ^8.0`): no
  `Catch variable $e is never read.` (the reported bug) and no native
  `true`/`false` return type suggestion, while non-capturing catch, named
  arguments and the PHPDoc-only `bool` return type are still reported.
  `PHP_VERSION_ID` is `int<70400, 80699>` and an explicit `PHP_VERSION_ID`
  check still narrows the scope further down.
* `composer-php-version-range-7` (`^7.4`): a variadic parameter is a `list`
  and a final private method produces no warning.
* `composer-php-version-range-8-0` (`^8.0`): a named argument after an
  unpacked argument is reported, because PHP 8.0 does not support it.
* `composer-php-version-range-8-5` (`^8.5`): `ini_get('max_memory_limit')`
  returns `string`.

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

Copy link
Copy Markdown
Collaborator Author

Addressed the review: all composer.json-dependent tests now live in e2e/ instead of tests/.

Removed the 8 unit/integration test classes and the 4 tests/PHPStan/Analyser/data/composer-require-php-* fixtures that used getComposerAutoloaderProjectPaths().

Added four e2e projects (composer.json + phpstan.neon + analysed files), one per require.php constraint, plus their matrix legs in .github/workflows/e2e-tests.yml:

e2e project constraint covers
composer-php-version-range-7-and-8 ^7.4 || ^8.0 no Catch variable $e is never read. (the reported bug) and no native true/false suggestion, while non-capturing catch, named arguments and the PHPDoc-only bool return type are still reported; PHP_VERSION_ID is int<70400, 80699> and an explicit check narrows it further
composer-php-version-range-7 ^7.4 variadic parameter is a list, final private method produces no warning
composer-php-version-range-8-0 ^8.0 named argument after unpacked argument is reported
composer-php-version-range-8-5 ^8.5 ini_get('max_memory_limit') returns string

Error-expecting projects assert via bashunit (exit_code 1 + contains/not_contains, with not_contains 'no-errors.php' guarding the files that must stay clean); the two error-free ones just assert a green bin/phpstan analyse.

Verification: each of the four projects was run locally with the fix (expected output exactly) and with src/Analyser/MutatingScope.php reverted to the pre-fix version — each one breaks without the fix (the two false-positive errors reappear in no-errors.php, the version-gated errors disappear, and the list<int> / string assertions fail). make tests (21923 tests, 96 skipped, 0 failures) and make phpstan (no errors) are green. Commit f2be2aac1 is pushed.

One thing worth flagging: while building the reproducer I found that merely mentioning PHP_VERSION_ID earlier in a file (e.g. in an if) changes whether catch.unusedVariable is reported later in that same file, because the scope then carries a constant type. That's why the assertions are split across separate files — the bug only surfaces in a file that never references PHP_VERSION_ID.

@staabm staabm 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.

could the fix be simplified by using ConfiguredPhpVersionRangeHelper?

…ngeHelper

MutatingScope::getPhpVersion() handled the NEON phpVersion min/max config
itself and went through ConstantResolver::resolvePredefinedConstant() for
the composer.json "require.php" range. ConfiguredPhpVersionRangeHelper
already answers both in one call, so ask it directly - the raw
"phpVersion" parameter is no longer needed in the scope factories.

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

Copy link
Copy Markdown
Collaborator Author

Addressed the review — the fix now goes through ConfiguredPhpVersionRangeHelper. Pushed as 12837f5cc.

What changed

MutatingScope::getPhpVersion() no longer has two separate fallbacks. The NEON phpVersion: {min, max} branch and the ConstantResolver::resolvePredefinedConstant('PHP_VERSION_ID') roundtrip are replaced by one call to the helper, which is the same source ConstantResolver narrows PHP_VERSION_ID with:

[$minPhpVersion, $maxPhpVersion] = $this->configuredPhpVersionRangeHelper->getVersionRange();
if (
    $minPhpVersion !== null
    || ($maxPhpVersion !== null && $maxPhpVersion->getVersionId() !== PhpVersionFactory::MAX_PHP_VERSION)
) {
    return new PhpVersions(IntegerRangeType::fromInterval(
        $minPhpVersion !== null ? $minPhpVersion->getVersionId() : ConstantResolver::PHP_MIN_ANALYZABLE_VERSION_ID,
        $maxPhpVersion !== null ? $maxPhpVersion->getVersionId() : null,
    ));
}

Since getPhpVersion() was the only consumer of the raw phpVersion parameter, the int|array{min,max}|null $configPhpVersion constructor argument is gone from MutatingScope, DirectInternalScopeFactory, DirectInternalScopeFactoryFactory and LazyInternalScopeFactory, replaced by the helper service (PHPStanTestCase now passes $container->getByType(ConfiguredPhpVersionRangeHelper::class)). The isOverallPhpVersionRange() helper for the scope-narrowed PHP_VERSION_ID type stays; the second call site it was extracted for is no longer needed.

Behaviour is preserved per source, because the helper distinguishes them: NEON int config → [null, null] → single analysed version; NEON range → both non-null → that range, exactly as before; no config and no composer constraint → [null, MAX_PHP_VERSION], which the condition treats as "no information" → single analysed version; composer constraint → the same range PHP_VERSION_ID reports (PHP_MIN_ANALYZABLE_VERSION_ID as the floor when the constraint has no lower bound).

Verification

  • All four e2e projects rerun locally against the refactored code: composer-php-version-range-7-and-8 reports exactly the three expected errors and nothing from no-errors.php/php-version-id.php; composer-php-version-range-8-0 reports the two expected errors; the -7 and -8-5 projects are clean.
  • make tests: 21923 tests, 97518 assertions, 96 skipped, 0 failures.
  • make phpstan: no errors. phpcs clean on the five touched files.

@staabm

staabm commented Sep 19, 2026

Copy link
Copy Markdown
Contributor

//cc @SanderMuller please review

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Copilot review overview

🟢 Approval recommended

The change consistently threads Composer/NEON-derived PHP version ranges into Scope::getPhpVersion() and adds targeted E2E coverage to prevent regressions across multiple version-gated consumers.

Review effort: Lite
Findings: None

What changed in this PR

This PR fixes inconsistencies in PHP-version–gated analysis by ensuring Scope::getPhpVersion() honors the project’s Composer require.php constraint (and NEON phpVersion range when configured), aligning scope-level version checks with how PHP_VERSION_ID is resolved elsewhere in analysis.

Changes:

  • Updated MutatingScope::getPhpVersion() to use ConfiguredPhpVersionRangeHelper (NEON phpVersion range or Composer require.php) when the scope doesn’t already carry a narrowed PHP_VERSION_ID.
  • Propagated ConfiguredPhpVersionRangeHelper through internal scope factories (and test scope factory wiring) instead of passing the raw phpVersion parameter.
  • Added E2E fixtures and workflow steps to verify behavior across Composer PHP constraints (^7.4, ^7.4 || ^8.0, ^8.0, ^8.5).
File Description
src/​Testing/​PHPStanTestCase.php Updates test scope factory wiring to pass ConfiguredPhpVersionRangeHelper into the scope factory stack.
src/​Analyser/​MutatingScope.php Implements the new getPhpVersion() fallback via ConfiguredPhpVersionRangeHelper and adds isOverallPhpVersionRange() helper.
src/​Analyser/​LazyInternalScopeFactory.php Lazily fetches and passes ConfiguredPhpVersionRangeHelper into created scopes.
src/​Analyser/​DirectInternalScopeFactoryFactory.php Switches constructor dependency from raw phpVersion config to ConfiguredPhpVersionRangeHelper.
src/​Analyser/​DirectInternalScopeFactory.php Threads ConfiguredPhpVersionRangeHelper through scope creation and factory flavor switching.
e2e/​composer-php-version-range-8-5/​phpstan.neon Adds E2E config for the ^8.5 Composer constraint scenario.
e2e/​composer-php-version-range-8-5/​no-errors.php Asserts expected PHP_VERSION_ID range and ini_get() typing under the ^8.5 constraint.
e2e/​composer-php-version-range-8-5/​composer.json Defines require.php: ^8.5 for the 8.5 E2E fixture.
e2e/​composer-php-version-range-8-5/​.gitignore Ignores vendor/ and composer.lock for the 8.5 E2E fixture.
e2e/​composer-php-version-range-8-0/​phpstan.neon Adds E2E config for the ^8.0 Composer constraint scenario.
e2e/​composer-php-version-range-8-0/​no-errors.php Asserts variadic parameter typing under the ^8.0 constraint.
e2e/​composer-php-version-range-8-0/​errors.php Introduces a version-gated error case (named-arg-after-unpacked) for the ^8.0 fixture.
e2e/​composer-php-version-range-8-0/​composer.json Defines require.php: ^8.0 for the 8.0 E2E fixture.
e2e/​composer-php-version-range-8-0/​.gitignore Ignores vendor/ and composer.lock for the 8.0 E2E fixture.
e2e/​composer-php-version-range-7/​phpstan.neon Adds E2E config for the ^7.4 Composer constraint scenario.
e2e/​composer-php-version-range-7/​no-errors.php Asserts expected PHP_VERSION_ID range and behavior under a PHP 7-only constraint.
e2e/​composer-php-version-range-7/​composer.json Defines require.php: ^7.4 for the 7.x E2E fixture.
e2e/​composer-php-version-range-7/​.gitignore Ignores vendor/ and composer.lock for the 7.x E2E fixture.
e2e/​composer-php-version-range-7-and-8/​phpstan.neon Adds E2E config (incl. bleeding edge) for the `^7.4
e2e/​composer-php-version-range-7-and-8/​php-version-id.php Asserts base and narrowed PHP_VERSION_ID ranges and non-capturing-catch gating behavior.
e2e/​composer-php-version-range-7-and-8/​no-errors.php Ensures version-gated rules don’t report “drop catch var” / “can be true” when PHP 7 is allowed.
e2e/​composer-php-version-range-7-and-8/​errors.php Ensures version-gated errors are still reported when appropriate under mixed 7/8 constraints.
e2e/​composer-php-version-range-7-and-8/​composer.json Defines `require.php: ^7.4
e2e/​composer-php-version-range-7-and-8/​.gitignore Ignores vendor/ and composer.lock for the mixed-range E2E fixture.
.github/​workflows/​e2e-tests.yml Runs the new E2E scenarios in CI and asserts expected raw output for error/no-error cases.

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

@staabm
staabm merged commit f80feb6 into phpstan:2.3.x Sep 19, 2026
828 of 896 checks passed
@staabm
staabm deleted the create-pull-request/patch-5oo2wq4 branch September 19, 2026 10:53
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.

"Catch variable $e is never read." reported on php7

4 participants