Make BackedEnum generic - #6273
Open
JanTvrdik wants to merge 1 commit into
Open
Conversation
JanTvrdik
force-pushed
the
generic-backed-enum
branch
from
August 26, 2026 09:34
b7dd913 to
5b921b1
Compare
There was a problem hiding this comment.
Pull request overview
This PR extends PHPStan’s understanding of PHP 8.1 backed enums by making BackedEnum generic over its backing type (int|string), and updating core type/reflection resolution so that the correct type argument is preserved through interface inheritance and value-of<...> evaluation.
Changes:
- Introduces a generic
BackedEnum<T of int|string = int|string>stub and wires it into the default stub set. - Adjusts interface/ancestor resolution (
ClassReflection::getInterfaces()andObjectType::getAncestorWithClassName()) to prefer the interface as resolved on the concrete class over the same interface reached via ancestors. - Updates
ValueOfTypelogic and adds regression coverage (nsrt + rule test) for backing-type-aware behavior and static calls tofrom()/tryFrom().
Reviewed changes
Copilot reviewed 8 out of 8 changed files in this pull request and generated 2 comments.
Show a summary per file
| File | Description |
|---|---|
| tests/PHPStan/Rules/Methods/data/generic-backed-enum.php | Adds rule-test data validating from()/tryFrom() parameter checking with BackedEnum<T>. |
| tests/PHPStan/Rules/Methods/CallStaticMethodsRuleTest.php | Adds a new test asserting the expected static-call errors for generic BackedEnum. |
| tests/PHPStan/Analyser/nsrt/generic-backed-enum.php | Adds nsrt coverage for generic backing-type propagation, interface inheritance, and value-of<T>. |
| stubs/BackedEnum.stub | Defines the generic BackedEnum stub (template + backing-type-aware $value). |
| src/Type/ValueOfType.php | Improves value-of<TemplateType of BackedEnum> resolution by extracting BackedEnum<T>’s active T. |
| src/Type/ObjectType.php | Adjusts ancestor lookup to prefer directly-resolved interfaces over those reached through other ancestors. |
| src/Reflection/ClassReflection.php | Ensures directly-resolved interfaces override inherited ones; injects implicit BackedEnum<T> for backed enums; uses template defaults for unspecified generic ancestors when fully defaulted. |
| conf/config.neon | Registers the new BackedEnum.stub in the default stub list. |
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
Comment on lines
+1874
to
1885
| // An interface resolved on this class itself is more specific than the same | ||
| // interface reached through one of its ancestors. | ||
| foreach ($this->getInterfaces() as $interface) { | ||
| if ($interface->getClassName() !== $className) { | ||
| continue; | ||
| } | ||
|
|
||
| return self::$ancestors[$description][$className] = $this->currentAncestors[$className] = $interface; | ||
| } | ||
|
|
||
| foreach ($this->getInterfaces() as $interface) { | ||
| $ancestor = $interface->getAncestorWithClassName($className); |
| $this->analyse([__DIR__ . '/data/bug-15002.php'], []); | ||
| } | ||
|
|
||
| #[RequiresPhp('>= 8.1')] |
JanTvrdik
force-pushed
the
generic-backed-enum
branch
from
August 26, 2026 09:40
5b921b1 to
427eca1
Compare
`BackedEnum` now carries the backing type: `@template-covariant T of int|string`.
`BackedEnum<string>::$value` is `string` instead of `int|string`, and
`value-of<T>` for `@template T of BackedEnum<string>` resolves to `string`.
`from()` and `tryFrom()` take a method-level `@template TValue of T` instead of
`@param T`. `T` stays out of contravariant position, which the variance check
forbids for a covariant type parameter, while `BackedEnum<int>::from('a')` is
still reported. `@throws` tags from phpstorm-stubs are preserved so that the
stub does not silence result-unused and dead-catch analysis around `from()`.
Three things keep this backwards compatible:
* `T` defaults to `int|string`, so a bare `BackedEnum` in PHPDoc keeps its old
meaning and is not reported as missing type arguments on level 6 and above.
* `T` is covariant, so every backed enum stays assignable to a bare
`BackedEnum`.
* PHP implements `BackedEnum` implicitly, so there is no `@implements` tag that
could carry the type argument. `ClassReflection::getImmediateInterfaces()`
fills it in from the enum backing type instead.
Two supporting changes were needed for that last point:
* `ClassReflection::getInterfaces()` and `ObjectType::getAncestorWithClassName()`
now prefer the interface as resolved on the class itself over the same
interface reached through one of its ancestors. Without this, an enum that
implements an interface which extends `BackedEnum` resolved to the ancestor
type arguments of that interface.
* A generic ancestor with no `@implements` or `@extends` tag now uses the
declared template defaults instead of error types, when every template type
has a default. This is what makes `interface Foo extends BackedEnum` resolve
to `BackedEnum<int|string>` rather than `BackedEnum<*ERROR*>`.
Because the interface is implemented implicitly, `EnumAncestorsRule` now
accepts an `@implements BackedEnum<int>` tag on a backed enum instead of
reporting that the enum "does not implement any interface", and reports
`enum.implementsBackingType` when the tag contradicts the actual backing type.
This gives downstream code that annotated backed enums this way (e.g. via
shipmonk/phpstan-rules' BackedEnumGenericsRule) a working migration path.
Co-Authored-By: Claude Code
Claude-Session: https://claude.ai/code/session_01QCdFyyKvMdEaU4bywFUist
JanTvrdik
force-pushed
the
generic-backed-enum
branch
from
August 26, 2026 10:03
427eca1 to
4138545
Compare
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Follow-up to #6272, but a lot more involved.
BackedEnumnow carries its backing type:The implicit
implementsproblemUnlike
SensitiveParameterValue,BackedEnumcannot get its type argument from a call site or from an@implementstag — PHP implements the interface implicitly, so no tag exists to carry it.ClassReflection::getImmediateInterfaces()fills it in from the enum backing type instead.Two supporting changes were needed to make that stick:
ClassReflection::getInterfaces()andObjectType::getAncestorWithClassName()now prefer the interface as resolved on the class itself over the same interface reached through one of its ancestors. Without this,enum E: string implements HasLabel(whereinterface HasLabel extends BackedEnum) resolvedBackedEnumthroughHasLabeland lost thestring.@implements/@extendstag now uses the declared template defaults instead of error types, when every template type has a default. This is what makesinterface Foo extends BackedEnumresolve toBackedEnum<int|string>rather thanBackedEnum<*ERROR*>.from()andtryFrom()They take a method-level
@template TValue of Trather than@param T $value.@param Twould put a covariant type parameter in contravariant position, whichVarianceCheckforbids in user code — PHPStan should not ship a stub doing what its own rule rejects. The method-level template keepsTin a bound only, and still reports:@returnstaysstatic. FeedingTValueinto the return type (BackedEnum<TValue>, orstatic&BackedEnum<TValue>) narrowsTto the argument literal, which is wrong —Tis the enum's declared backing type, not the value of one instance. It makes this a false positive:It also degrades
S::from($s)fromStoBackedEnum<string>.The stub preserves the
@throws ValueError/@throws TypeErrortags from phpstorm-stubs — stub docblocks replace them wholesale, and dropping them madeSE::from($x);statements reportresultUnusedand turnedcatch (ValueError|TypeError)aroundfrom()into dead catches.Side note:
static&BackedEnum<TValue>turned out to be a no-op anyway. Intersecting a bareObjectType('BackedEnum')withGenericObjectType('BackedEnum', ['a'])keeps the bare one instead of the generic one. That looks like aTypeCombinatorquirk unrelated to this PR; I have not touched it.@implements BackedEnum<int>is now a valid annotationBecause the interface is implemented implicitly,
EnumAncestorsRulepreviously rejected the tag with "has @implements tag, but does not implement any interface" — even though a backed enum genuinely implementsBackedEnum. It now accepts the tag, and reports a newenum.implementsBackingTypeerror when the tag contradicts the actual backing type:The tag is redundant for inference (the backing type always wins), but downstream code annotated this way — notably the ecosystem around shipmonk/phpstan-rules'
BackedEnumGenericsRule, which simulated genericBackedEnumin userland — gets a working migration path instead of a hard error.Backward compatibility
Three properties keep this from breaking existing code:
Tdefaults toint|string, so a bareBackedEnumin PHPDoc keeps its old meaning and is not reported as missing type arguments on level 6 and above.Tis covariant, so every backed enum stays assignable to a bareBackedEnum.New errors appear only once someone opts in by writing
BackedEnum<string>.Measured on carbon, symfony/console + http-foundation + validator, doctrine/dbal, league/csv, monolog, guzzle and brick/money — 29 enum declarations, level 9, 6218 errors before the change: 0 new, 0 gone. The single delta is message wording: calls through the interface now read
… BackedEnum<int|string>::from() expects TValue of int|string, mixed giveninstead of… BackedEnum::from() expects int|string, mixed given, so baseline entries matching the old wording for such calls need regenerating (concrete-enum messages are unchanged).Known ecosystem impact, verified via this repo's integration matrix (everything else that is red there fails identically for sibling PRs):
compatibility/BackedEnum.stubnow collides with the bundled stub (Class BackedEnum declared multiple times). Needs a phpstan-doctrine release dropping (or version-guarding) that stub.BackedEnumGenericsRuleactivates (it fires when theBackedEnumancestorisGeneric()), demanding@implementstags. The rule's own suggestion now works thanks to theEnumAncestorsRulechange above, and the userland emulation is planned to be dropped in favour of this native support.One more behavioural note:
ObjectType::getAncestorWithClassName()now prefers the interface as resolved on the class itself; in ambiguous diamonds (class C implements A, BwhereA extends I<int>,B extends I<string>) the resolution can change — it now matches whatClassReflection::getAncestorWithClassName()already did.The sharpest general edge is the template-defaults change: for any generic class with defaults that is extended without a tag, the argument goes from
*ERROR*(permissive) to the default (stricter). The blast radius is small —FFI\CDatais the only bundled stub that declares template defaults at all.Two behaviours worth calling out for review:
interface Foo extends BackedEnumwithout@extends BackedEnum<string>resolves toBackedEnum<int|string>, soFoois not accepted whereBackedEnum<string>is expected. A one-line@extendstag fixes it, but it is a real papercut for existing interfaces (pinned in the acceptance rule test).StringEnum::from($int)on a concrete enum is still unchecked — the enum's own nativefrom(int|string)wins over the inherited stub template. That gap predates this PR and would need a separate rule.enum Wrong: int implements StringBackedwhereStringBackedhas@extends BackedEnum<string>) resolves silently toBackedEnum<int>— the actual backing type wins; the interface-level contradiction is not yet diagnosed.