Skip to content

Resolve property, trait constant, arrow function never and attribute PHP version checks from Scope::getPhpVersion() - #6510

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

staabm merged 4 commits into
phpstan:2.3.xfrom
phpstan-bot:create-pull-request/patch-6ntl8qp

Conversation

@phpstan-bot

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

Copy link
Copy Markdown
Collaborator

Summary

Code that is guarded by a if (PHP_VERSION_ID >= 8xx00) check was still being reported with non-ignorable "… is supported only on PHP X and later." errors, which cannot be baselined or ignored.

#6496 converted a first batch of those rules to Scope::getPhpVersion(). This finishes the sweep: every rule in that family now resolves the version from the scope, so a conditionally declared class/interface/trait/property/expression inside a PHP_VERSION_ID guard is judged against the version range that actually reaches it.

Changes

src/Php/PhpVersions.php — new range-aware queries:
supportsConstantsInTraits(), supportsNeverReturnTypeInArrowFunction(), supportsArrayUnpackingWithStringKeys(), supportsPropertyHooks(), supportsFinalProperties(), supportsAsymmetricVisibilityForStaticProperties(), supportsOverrideAttributeOnProperty(), supportsAttributesOnGlobalConstants(), supportsUnsetCast().

Rules converted to $scope->getPhpVersion() (and their PhpVersion constructor dependency dropped):

  • src/Rules/Properties/PropertyInClassRule.php — all three gates: Final properties are supported only on PHP 8.4 and later., Asymmetric visibility for static properties is supported only on PHP 8.5 and later., Property hooks are supported only on PHP 8.4 and later.
  • src/Rules/Properties/PropertiesInInterfaceRule.phpInterfaces can include properties only on PHP 8.4 and later.
  • src/Rules/Properties/PropertyAttributesRule.phpAttribute class Override can be used with properties only on PHP 8.5 and later.
  • src/Rules/Traits/ConstantsInTraitsRule.phpConstant is declared inside a trait but is only supported on PHP 8.2 and later.
  • src/Rules/Functions/ExistingClassesInArrowFunctionTypehintsRule.phpNever return type in arrow function is supported only on PHP 8.2 and later.
  • src/Rules/Constants/ConstantAttributesRule.phpAttributes on global constants are supported only on PHP 8.5 and later.
  • src/Rules/Cast/UnsetCastRule.phpThe (unset) cast is no longer supported in PHP 8.0 and later.
  • src/Rules/Arrays/ArrayUnpackingRule.php — the 8.1 gate around Array unpacking cannot be used on an array with string keys.

Probed and found to belong to a different family, so left as-is (they select between two valid behaviours instead of reporting unsupported syntax, and a Maybe answer has no obviously correct resolution):

  • supportsPureIntersectionTypes() in FunctionDefinitionCheck and ExistingClassesInPropertiesRule
  • supportsReturnCovariance() / supportsParameterContravariance() / supportsParameterTypeWidening() / supportsLessOverridenParametersWithVariadic() in OverridingMethodRule and MethodParameterComparisonHelper
  • supportsPropertyHooks() / supportsOverrideAttributeOnProperty() in OverridingPropertyRule, supportsAsymmetricVisibility*() in the property access checks
  • the deprecates*() gates in DeprecatedCastRule and BacktickRule (ignorable deprecation reports, not non-ignorable syntax-support errors)

Root cause

The pattern is that a rule asked the DI-injected PhpVersion — a single, file-wide version — whether a language feature exists, instead of asking Scope::getPhpVersion(), which returns a PhpVersions range narrowed by any surrounding PHP_VERSION_ID comparison. Because the injected value never reflects control flow, code inside if (PHP_VERSION_ID >= 80300) { … } was judged against the analysed version rather than the version range that can actually reach that branch, producing non-ignorable errors on deliberately version-gated declarations.

The fix is mechanical and uniform: query the scope, and treat only a definite yes() as "supported" (for the inverse-direction UnsetCastRule, only a definite no() as "must report"), so an uncertain range keeps the previous, conservative outcome.

ConstantAttributesRule is a special member: const cannot appear inside a conditional block in PHP, so its scope narrowing can only come from the phpVersion min/max NEON configuration — it is covered by a config-driven test instead.

Test

The reported case (native typed class constants, NativeTypedClassConstantRule) is already covered by tests/PHPStan/Rules/Constants/data/bug-13133.php, and the reproducer from the issue produces no errors with phpVersion: 80200.

New regression tests, each with a data file declaring the construct three times — inside a supporting PHP_VERSION_ID branch, inside a non-supporting branch, and unguarded:

  • tests/PHPStan/Rules/Properties/data/property-in-class-php-versions.php + PropertyInClassRuleTest::testPhpVersionNarrowedScope()
  • tests/PHPStan/Rules/Properties/data/properties-in-interface-php-versions.php + PropertiesInInterfaceRuleTest::testPhpVersionNarrowedScope()
  • tests/PHPStan/Rules/Properties/data/override-attr-on-property-php-versions.php + PropertyAttributesRuleTest::testOverrideAttributePhpVersionNarrowedScope()
  • tests/PHPStan/Rules/Traits/data/constants-in-traits-php-versions.php + ConstantsInTraitsRuleTest::testPhpVersionNarrowedScope() (the guard sits on the consuming class, since a trait body is analysed through its use site)
  • tests/PHPStan/Rules/Functions/data/arrow-function-never-php-versions.php + ExistingClassesInArrowFunctionTypehintsRuleTest::testNeverPhpVersionNarrowedScope()
  • tests/PHPStan/Rules/Cast/data/unset-cast-php-versions.php + UnsetCastRuleTest::testPhpVersionNarrowedScope()
  • tests/PHPStan/Rules/Arrays/data/array-unpacking-php-versions.php + ArrayUnpackingRuleTest::testPhpVersionNarrowedScope()
  • tests/PHPStan/Rules/Constants/ConstantAttributesRuleConfigPhpTest + data/constant-attributes-php-version.neon (phpVersion range instead of an inline guard)

All eight fail when the rules are put back on a non-narrowing PhpVersions, and pass with the change. The existing UnsetCastRuleTest, ConstantsInTraitsRuleTest, ReadOnlyPropertyRuleTest-style per-version data providers were rewritten to key off the runtime PHP_VERSION_ID, matching the convention introduced in #6496.

Fixes phpstan/phpstan#13133

refs #3642 (comment)

…e PHP version checks from `Scope::getPhpVersion()`

* Added `supportsConstantsInTraits()`, `supportsNeverReturnTypeInArrowFunction()`, `supportsArrayUnpackingWithStringKeys()`, `supportsPropertyHooks()`, `supportsFinalProperties()`, `supportsAsymmetricVisibilityForStaticProperties()`, `supportsOverrideAttributeOnProperty()`, `supportsAttributesOnGlobalConstants()` and `supportsUnsetCast()` to `PhpVersions`.
* `PropertyInClassRule` now resolves the final-property (8.4), static asymmetric-visibility (8.5) and property-hooks (8.4) checks from the scope.
* `PropertiesInInterfaceRule`, `PropertyAttributesRule`, `ConstantsInTraitsRule`, `ExistingClassesInArrowFunctionTypehintsRule`, `ConstantAttributesRule`, `UnsetCastRule` and `ArrayUnpackingRule` do the same for their own version gates.
* `UnsetCastRule` reports only when the scope version is definitely >= 8.0 (`supportsUnsetCast()->no()`), which is the inverse-direction member of the family.
* Dropped the now-unused `PhpVersion` constructor dependencies from all of these rules.
* Probed and deliberately left alone: `supportsPureIntersectionTypes()` in `FunctionDefinitionCheck`/`ExistingClassesInPropertiesRule` and the covariance/visibility/deprecation gates in `OverridingMethodRule`, `MethodParameterComparisonHelper`, `OverridingPropertyRule`, `DeprecatedCastRule` and `BacktickRule` - those pick between two valid behaviours rather than emitting a non-ignorable "supported only on PHP X" error.

Closes phpstan/phpstan#13133
@staabm
staabm force-pushed the create-pull-request/patch-6ntl8qp branch from 9f4e6b9 to 71ffcb4 Compare September 22, 2026 07:49
@staabm

staabm commented Sep 22, 2026

Copy link
Copy Markdown
Contributor

//cc @SanderMuller

@SanderMuller

Copy link
Copy Markdown
Contributor

UnsetCastRule is the only conversion here using !...->no(); the other nine use ...->yes().
That flips the behaviour on a straddling range.

supportsUnsetCast() is IntegerRangeType::fromInterval(null, 79999), so a range spanning 8.0
answers maybe, !maybe->no() is true, and the rule returns early. Measured on this branch:

phpVersion (unset) cast reported
70400 no, correct
80100 yes, correct
min 70400, max 80100 no

The cast does break on the 8.0 half of that range, and the other rules report in the same
situation. PropertyInClassRule with a final property, same branch: 8.3 reports, 8.5 does not,
min 80300 max 80500 reports.

nonIgnorable() does not explain it either, since ConstantsInTraitsRule is also non-ignorable and
uses ->yes().

Intended? For a feature that was removed rather than added there is an argument for staying quiet
unless certain. It just reads differently from the rest.

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

adjust per #6510 (comment)

should be $scope->getPhpVersion()->supportsUnsetCast()->yes()

`UnsetCastRule` was the only rule in the `Scope::getPhpVersion()` sweep
using `!...->no()` instead of `...->yes()`, which made it stay silent when
the version range straddles 8.0 — even though the cast is a fatal error on
the 8.0 half of that range. Align it with the other nine rules: return
early only when the cast is definitely still supported.

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

Copy link
Copy Markdown
Collaborator Author

Done — pushed as 1e86b5ec8.

Change (src/Rules/Cast/UnsetCastRule.php:25): if (!$scope->getPhpVersion()->supportsUnsetCast()->no())if ($scope->getPhpVersion()->supportsUnsetCast()->yes()). The rule now matches the other nine conversions in the PR: bail out only on a definite "supported", report otherwise.

Behaviour delta — only the straddling-range case changes, which is exactly what SanderMuller's table flagged:

phpVersion before after
70400 not reported not reported
80100 reported reported
min 70400, max 80100 not reported reported

Test added: UnsetCastRuleConfigPhpTest + data/unset-cast-php-version.neon (min: 70400, max: 80100), analysing the existing data/unset-cast-php-versions.php. It pins all three positions under one straddling range: the cast inside if (PHP_VERSION_ID < 80000) stays silent (narrowed to 70400–79999, definite yes), the one inside if (PHP_VERSION_ID >= 80000) reports (definite no), and the unguarded one now reports (maybe).

Verified per the repo's stash-and-rerun rule: with the rule reverted to !...->no() the new test fails on exactly the missing line-14 error, and passes with the change. make tests — 22026 tests, OK (96 skipped, pre-existing). make phpstan — no errors. make cs was not run; phpcs isn't installed in this environment, so the new file is styled after the existing FinalPrivateMethodRuleConfigPhpTest rather than checked.

@staabm
staabm merged commit 6ae78b9 into phpstan:2.3.x Sep 22, 2026
531 of 536 checks passed
@staabm
staabm deleted the create-pull-request/patch-6ntl8qp branch September 22, 2026 09:02
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