Skip to content

Resolve "supported only on PHP X" checks from Scope::getPhpVersion() instead of the injected PhpVersion - #6496

Merged
staabm merged 11 commits into
phpstan:2.3.xfrom
phpstan-bot:create-pull-request/patch-salupum
Sep 22, 2026
Merged

staabm merged 11 commits into
phpstan:2.3.xfrom
phpstan-bot:create-pull-request/patch-salupum

Conversation

@phpstan-bot

@phpstan-bot phpstan-bot commented Sep 21, 2026

Copy link
Copy Markdown
Collaborator

Summary

A class declared inside if (PHP_VERSION_ID >= 80300) { … } was still checked against the configured PHP version, so analysing on PHP 8.2 reported the non-ignorable, non-baselineable error Class constants with native types are supported only on PHP 8.3 and later. for a public const string BAR = 'bar'; that can only ever be reached on PHP 8.3+.

The rule asked the DI-injected PhpVersion service, which knows nothing about control flow. Scope::getPhpVersion() already narrows the analysed PHP version from the PHP_VERSION_ID constant in scope (the mechanism introduced in #3662 for NoncapturingCatchRule / FinalPrivateMethodRule). This PR moves the whole family of "… is supported only on PHP X and later." checks over to it.

Changes

  • src/Php/PhpVersions.php — added 20 range-aware supports*(): TrinaryLogic methods mirroring the PhpVersion booleans: supportsThrowExpression, supportsClassConstantOnExpression, supportsPromotedProperties, supportsNativeUnionTypes, supportsFinalConstants, supportsReadOnlyProperties, supportsFirstClassCallables, supportsReadOnlyClasses, supportsNeverReturnTypeInArrowFunction, supportsNativeTypesInClassConstants, supportsDynamicClassConstantFetch, supportsReadOnlyAnonymousClasses, supportsPropertyHooks, supportsFinalProperties, supportsAsymmetricVisibilityForStaticProperties, supportsFinalPromotedProperties, supportsVoidCast, supportsAttributesOnGlobalConstants, supportsDeprecatedTraits, supportsOverrideAttributeOnProperty.
  • Rules switched to $scope->getPhpVersion()->supportsX()->yes() (and their now-unused PhpVersion constructor dependency removed):
    • src/Rules/Constants/NativeTypedClassConstantRule.php — the reported case
    • src/Rules/Constants/FinalConstantRule.php, src/Rules/Constants/DynamicClassConstantFetchRule.php, src/Rules/Constants/ConstantAttributesRule.php
    • src/Rules/Classes/ReadOnlyClassRule.php (readonly classes and anonymous readonly classes), src/Rules/Classes/ClassConstantRule.php (::class on an expression), src/Rules/Classes/InvalidPromotedPropertiesRule.php (promoted and final promoted properties)
    • src/Rules/Properties/ReadOnlyPropertyRule.php, src/Rules/Properties/PropertiesInInterfaceRule.php, src/Rules/Properties/PropertyInClassRule.php (final properties, property hooks, asymmetric visibility for static properties), src/Rules/Properties/PropertyAttributesRule.php
    • src/Rules/Methods/MethodCallableRule.php, src/Rules/Methods/StaticMethodCallableRule.php, src/Rules/Functions/FunctionCallableRule.php — first-class callables
    • src/Rules/Cast/VoidCastRule.php, src/Rules/Exceptions/ThrowExpressionRule.php, src/Rules/Traits/TraitAttributesRule.php, src/Rules/Functions/ExistingClassesInArrowFunctionTypehintsRule.php
    • src/Rules/FunctionDefinitionCheck.php — all three native-union-type sites, which cover Functions/, Methods/, closure, arrow-function and property-hook ExistingClassesIn*TypehintsRule
  • Probed and found already correct (no change needed): src/Rules/FunctionCallParametersCheck.php (named arguments), src/Rules/Exceptions/NoncapturingCatchRule.php, src/Rules/Methods/FinalPrivateMethodRule.php — these already read the version off the scope.
  • Deliberately left alone: PhpVersion uses that drive semantics rather than a syntax-support error (OverridingPropertyRule, AccessPropertiesCheck, ReadOnlyPropertyAssignRule, the deprecates* checks in FunctionDefinitionCheck). Those are not "this syntax does not parse/exist on your PHP version" errors and changing them is a different, much larger behavioural question.
  • Test infrastructure: added tests/PHPStan/Rules/php-version-{70400,80000,80100,80200,80300,80400,80500}.neon. Rule tests that used to hand a fake PhpVersion to the rule now configure the analysed version through the container via getAdditionalConfigFiles(), so the scope and the rule agree on the version.

Root cause

Two sources of truth for "which PHP version is being analysed":

  • the DI PhpVersion service — a single value derived from config/composer/runtime, with no notion of where in the file we are;
  • Scope::getPhpVersion() — a PhpVersions value object wrapping the type of PHP_VERSION_ID in the current scope, so if (PHP_VERSION_ID >= 80300) narrows it to int<80300, max>.

Every rule that reported a non-ignorable "supported only on PHP X and later" error used the first one, so version-gated declarations were always judged against the lowest supported version. Because the errors are nonIgnorable(), they cannot be silenced with @phpstan-ignore or a baseline — the code was unanalysable. PhpVersions only exposed six supports*() methods, which is why the pattern had only ever been applied to two rules; the fix is to complete that API and sweep the whole family.

->yes() is the right predicate: when the analysed version is an uncertain range that straddles the threshold, the answer is maybe and the error is still reported — matching today's behaviour for a configured phpVersion: {min, max} range.

Test

  • tests/PHPStan/Rules/Constants/data/bug-13133.php + NativeTypedClassConstantRuleTest::testBug13133() — the OP's playground snippet verbatim, analysed with phpVersion: 80200. Only the unguarded class is reported.
  • Analogous *-php-versions.php fixtures + testConditionally*() methods for: final class constants, readonly properties, readonly/anonymous readonly classes, promoted properties, throw expressions, (void) casts, first-class callables (method, static method and function variants), dynamic class constant fetch, final properties, properties in interfaces, ::class on an expression, #[Override] on properties, native union types, and never return type in arrow functions. Each fixture declares the construct three times — guarded by if (PHP_VERSION_ID >= X), guarded by if (PHP_VERSION_ID < X), and unguarded — and expects errors only for the last two.
  • All 16 new testConditionally* / testBug13133 cases were verified to fail when the PHP_VERSION_ID narrowing is removed from MutatingScope::getPhpVersion(), and pass with it.
  • Existing version-parameterised rule tests were reworked onto the container-level phpVersion configuration; make tests (22015 tests), make phpstan and make cs-fix are green. make name-collision fails on the pre-existing tests/PHPStan/Rules/Methods/data/static-call-pipe.php parse error, unrelated to this change.

Fixes phpstan/phpstan#13133
refs #3642 (comment)

🤖 Generated with Claude Code

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

don't add new NEON files for tests, but just narrow using if (PHP_VERSION_ID >= 80300) { as in #6497

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

resolve the conflict and rebase

@VincentLanglet
VincentLanglet force-pushed the create-pull-request/patch-salupum branch from 03a6fd4 to 01bbcea Compare September 21, 2026 15:43

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

don't add new NEON files for tests, but just narrow using if (PHP_VERSION_ID >= 80300) { as in #6497

@phpstan-bot

Copy link
Copy Markdown
Collaborator Author

I processed this review but have nothing to report.

@VincentLanglet
VincentLanglet force-pushed the create-pull-request/patch-salupum branch from 01bbcea to b455c3e Compare September 21, 2026 15:54

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

Rather than using self::$analysedPhpVersionId, you can have conditional error message in test, like

public function testRule(): void
	{
		$errors = [];
		if (PHP_VERSION_ID < 80300) {
			$errors = [
				[
					'Class constants with native types are supported only on PHP 8.3 and later.',
					10,
				],
			];
		}

		$this->analyse([__DIR__ . '/data/native-typed-class-constant.php'], $errors);
	}

This avoid every setup and getAdditionalConfigFiles

@phpstan-bot

Copy link
Copy Markdown
Collaborator Author

That monitor was watching for the paratest run to finish; it's stale now — the suite completed earlier (22009 tests, exit 0), and I've since re-run make tests, make phpstan, and make cs on the final committed state, all green. No re-arm needed.

The work is complete and pushed to create-pull-request/patch-salupum as three commits:

  1. 1ac81bf60 — Keep ConstantAttributesRule on the injected PhpVersion
  2. 220ec2f54 — Express the analysed PHP version in rule tests with PHP_VERSION_ID instead of config files
  3. ee8fa6485 — Add back the bug 13133 reproducer test

staabm and others added 5 commits September 21, 2026 21:15
…` instead of the injected `PhpVersion`

- Add 20 range-aware `supports*(): TrinaryLogic` methods to `PHPStan\Php\PhpVersions` mirroring their `PhpVersion` counterparts, so scope-narrowed PHP version ranges can answer feature-support queries.
- Switch every rule that emits a non-ignorable "… is supported only on PHP X and later." error to `$scope->getPhpVersion()->supportsX()->yes()`, and drop the now-unused `PhpVersion` constructor dependency:
  - `Constants/NativeTypedClassConstantRule` (the reported case), `Constants/FinalConstantRule`, `Constants/DynamicClassConstantFetchRule`, `Constants/ConstantAttributesRule`
  - `Classes/ReadOnlyClassRule` (both readonly and anonymous readonly classes), `Classes/ClassConstantRule` (`::class` on an expression), `Classes/InvalidPromotedPropertiesRule` (promoted and final promoted properties)
  - `Properties/ReadOnlyPropertyRule`, `Properties/PropertiesInInterfaceRule`, `Properties/PropertyInClassRule` (final properties, property hooks, asymmetric visibility on static properties), `Properties/PropertyAttributesRule`
  - `Methods/MethodCallableRule`, `Methods/StaticMethodCallableRule`, `Functions/FunctionCallableRule` (first-class callables)
  - `Cast/VoidCastRule`, `Exceptions/ThrowExpressionRule`, `Traits/TraitAttributesRule`, `Functions/ExistingClassesInArrowFunctionTypehintsRule` (never return type)
  - `FunctionDefinitionCheck` native union types, covering the four `ExistingClassesIn*TypehintsRule` rules and property hooks
- Add `tests/PHPStan/Rules/php-version-*.neon` fixtures and rework the affected rule tests to configure the analysed PHP version through the container instead of handing a fake `PhpVersion` to the rule, so the scope agrees with the configured version.
- Add 17 regression fixtures declaring the same construct inside `if (PHP_VERSION_ID >= X)`, inside `if (PHP_VERSION_ID < X)` and unguarded; only the latter two are expected to be reported.
- `FunctionCallParametersCheck` (named arguments), `NoncapturingCatchRule` and `FinalPrivateMethodRule` already used the scope and needed no change.

Closes phpstan/phpstan#13133

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
A global `const` declaration is a parse error inside an `if` block, so
`Scope::getPhpVersion()` can never be narrower than the configured PHP
version for this rule - the switch was a no-op that only made the
"ConstantAttributesRule requires PHP 8.5 runtime to check the code."
branch untestable without a dedicated config file.

Also drop the now-unused PhpVersions::supportsAttributesOnGlobalConstants().

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…stead of config files

Rule tests now let the analysed version be the runtime one and make the
expected errors conditional on PHP_VERSION_ID, the same way
NativeTypedClassConstantRuleTest does. The "not supported on PHP X"
branches stay covered by the `if (PHP_VERSION_ID < X)` declarations in
the regression fixtures, which narrow the scope regardless of the
runtime version.

This removes the tests/PHPStan/Rules/php-version-*.neon files together
with the per-test setUp()/getAdditionalConfigFiles() plumbing that
selected them.

The throw expression fixture gets one method per case: with all three
cases in a single method, a conditional `throw` in an earlier branch
narrows PHP_VERSION_ID in the later ones.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The test method was lost while resolving the conflict with phpstan#6497, which
left data/bug-13133.php unreferenced and the `use const PHP_VERSION_ID;`
import removed from a file that still uses it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@staabm
staabm force-pushed the create-pull-request/patch-salupum branch from ee8fa64 to 93fe4cc Compare September 21, 2026 19:15

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

revert changes to

  • PropertyAttributesRule*
  • ExistingClassesInArrowFunctionTypehintsRule*
  • PropertiesInInterfaceRule*
  • PropertyInClassRule*

phpstan-bot and others added 4 commits September 21, 2026 19:32
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…ersion

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

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

Copy link
Copy Markdown
Collaborator Author

Done — the four rules are reverted and pushed as four commits.

What changed

Commit Contents
7241c8ce1 PropertyAttributesRule back on the injected PhpVersion; test, override-attr-on-property-php-versions.php fixture and its Makefile lint exclusion removed
b53eacf73 ExistingClassesInArrowFunctionTypehintsRule back on the injected PhpVersion; arrow-function-never-php-versions.php fixture removed
9e7edd7f4 PropertiesInInterfaceRule and PropertyInClassRule back on the injected PhpVersion (they share supportsPropertyHooks, so one commit); both fixtures removed
512341faf Dropped the five PhpVersions methods left without a caller

All four rule sources, their three test classes and the Makefile are now byte-identical to the base commit — verified with an empty git diff against 1227228e4.

Two things worth flagging:

The arrow-function test was only partially reverted. ExistingClassesInArrowFunctionTypehintsRuleTest also carries changes driven by FunctionDefinitionCheck (the native-union-type sites, which this review did not ask to revert), so a wholesale revert wasn't possible. I restored the new PhpVersion(PHP_VERSION_ID) rule argument and removed testConditionallyExecutedArrowFunction, and left the dataNativeUnionTypestestNativeUnionTypes rework in place.

I removed five PhpVersions methods rather than leaving them dead. supportsNeverReturnTypeInArrowFunction, supportsOverrideAttributeOnProperty, supportsPropertyHooks, supportsFinalProperties and supportsAsymmetricVisibilityForStaticProperties had no remaining caller after the reverts. PhpVersions is @api, so keeping them would be harmless and arguably completes the mirror of PhpVersion — if you'd rather have the API surface complete, 512341faf is a standalone commit and can simply be dropped.

Verification

  • make tests — 22007 tests, 97631 assertions, 96 skipped, no failures
  • make phpstan — no errors
  • make cs — clean

@staabm

staabm commented Sep 21, 2026

Copy link
Copy Markdown
Contributor

//cc @SanderMuller

@staabm

staabm commented Sep 21, 2026

Copy link
Copy Markdown
Contributor

I have isolated rules which were easy to review and need similar changes in this PR.
will submit another PR with the more complicated ones after this one got merged.

@SanderMuller

Copy link
Copy Markdown
Contributor

On the performance question from the Slack thread: it is answered, and by #6481.

I measured MutatingScope::getPhpVersion() against 77bb30327 and its parent, on a single-process
src/ analysis of this repository. 5.38us per call before, 1.07us after, 80% off. Over the
95369 calls that already happen, 0.51s down to 0.10s.

That reframes what this PR costs. I counted the rules it touches by instrumenting the rule dispatch:
they are invoked 31433 times on that run, out of 12.1M rule invocations in total. At the old
price that would have been 0.17s. At today's it is 0.034s, so the migration is free in practice.

On the open question about the five PhpVersions methods left without a caller: removing them is
not a BC break. PhpVersions is @api, but none of the five exists on 2.2.x - this PR added them,
so they have never shipped. Keeping or dropping 512341faf is a taste call about shipping API
surface ahead of its callers, not a compatibility one.

Numbers are one corpus, and 31433 is an upper bound, since some of these rules return before they
reach the version check.

@staabm
staabm removed the request for review from VincentLanglet September 22, 2026 07:14
@staabm
staabm merged commit 76aa1af into phpstan:2.3.x Sep 22, 2026
572 of 578 checks passed
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.

Conditionally defined classes will be evaluated regardless of the current version

4 participants