Skip to content

Fix #13828: Reference to static const behaves as self const#4906

Open
phpstan-bot wants to merge 7 commits into2.1.xfrom
create-pull-request/patch-gripjte
Open

Fix #13828: Reference to static const behaves as self const#4906
phpstan-bot wants to merge 7 commits into2.1.xfrom
create-pull-request/patch-gripjte

Conversation

@phpstan-bot
Copy link
Collaborator

Summary

When a parent class declared @return static::SOME_CONST in a method's PHPDoc, PHPStan treated static:: identically to self:: — always resolving to the declaring class's constant value. This meant subclass constant overrides were ignored: calling the method on a child class that overrides the constant still returned the parent's value.

Changes

  • Created new src/Type/ClassConstantAccessType.php — a LateResolvableType that wraps a Type (initially StaticType) and a constant name, deferring resolution until the caller type is known
  • Modified src/PhpDoc/TypeNodeResolver.php:
    • resolveConstTypeNode(): separated static and self cases; for static::CONST on non-final classes, returns ClassConstantAccessType(StaticType, constantName) instead of eagerly resolving
    • resolveArrayShapeOffsetType(): same fix for array shape key contexts
    • Wildcard static::CONST_* patterns use getValueType() fallback
  • Updated CLAUDE.md with documentation of the new pattern

Root cause

TypeNodeResolver::resolveConstTypeNode() handled static and self in a single case block, resolving both to $nameScope->getClassName() and then immediately computing the constant's literal value from the declaring class. This lost the late static binding semantics of static::.

The fix introduces ClassConstantAccessType, which preserves the StaticType inside the return type. When a method is called on a specific class (e.g., BarBaz), CalledOnTypeUnresolvedMethodPrototypeReflection::transformStaticType() traverses the return type and replaces StaticType with ObjectType('BarBaz'). The ClassConstantAccessType::traverse() method creates a new instance with the replaced inner type, and getResult() then resolves the constant on the correct class — giving BarBaz::FOO_BAR instead of FooBar::FOO_BAR.

Test

Added tests/PHPStan/Analyser/nsrt/bug-13828.php which verifies:

  • FooBar with const FOO_BAR = 'foo' and @return static::FOO_BAR resolves to 'foo' when called on FooBar
  • BarBaz extends FooBar with const FOO_BAR = 'bar' resolves to 'bar' when called on BarBaz
  • Final class with @return static::FOO_BAR resolves to the literal value 'foo'

Fixes phpstan/phpstan#13828

Copy link
Member

@ondrejmirtes ondrejmirtes left a comment

Choose a reason for hiding this comment

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

This is not entirelt correct. When the constant has a native type or a PHPDoc type, use that when accessed on an expression if the class is not final. Generate extra tests for this scenario and then fix them.

Copy link
Member

@ondrejmirtes ondrejmirtes left a comment

Choose a reason for hiding this comment

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

@phpstan-bot This is not entirelt correct. When the constant has a native type or a PHPDoc type, use that when accessed on an expression if the class is not final. Generate extra tests for this scenario and then fix them.

@phpstan-bot
Copy link
Collaborator Author

Claude Code is working…

I'll analyze this and get back to you.

View job run

Copy link
Member

@ondrejmirtes ondrejmirtes left a comment

Choose a reason for hiding this comment

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

@phpstan-bot This is not entirelt correct. When the constant has a native type or a PHPDoc type, use that when accessed on an expression if the class is not final. Generate extra tests for this scenario and then fix them.

@phpstan-bot
Copy link
Collaborator Author

phpstan-bot commented Feb 16, 2026

Claude encountered an error —— View job


Working on the requested changes…

Tasks

  • Read current implementation and understand the issue
  • Understand how constant native/PHPDoc types work in PHPStan
  • Generate extra tests for constants with native type and PHPDoc type
  • Fix ClassConstantAccessType::getResult() to use declared types for non-final classes
  • Run tests to verify
  • Commit and push

Changes made

  1. ClassConstantAccessType::getResult() — When the inner type is still a StaticType on a non-final class:

    • If the constant is non-final, has no native type, and no PHPDoc type → returns mixed
    • If the constant has a native type or PHPDoc type → returns getValueType() (the declared type)
    • Final constants or final classes → returns the literal value as before
  2. Test cases added for:

    • Constant with native type (const string FOO_BAR) → resolves to string
    • Constant with PHPDoc type (@var non-empty-string) → resolves to non-empty-string
    • Constant with both types → resolves to non-empty-string
    • Final constant on non-final class → resolves to literal value
    • Untyped constant on non-final class → resolves to mixed
    • Final child class → resolves to literal value

@ondrejmirtes
Copy link
Member

@phpstan-bot You crashed last time. Now I'm retrying without --max-turns option. The diff is not entirely correct. When the constant has a native type or a PHPDoc type, use that when accessed on an expression if the class is not final. Generate extra tests for this scenario and then fix them.

@ondrejmirtes ondrejmirtes force-pushed the create-pull-request/patch-gripjte branch from a2c60cd to b0abcf3 Compare February 16, 2026 08:12
@phpstan-bot
Copy link
Collaborator Author

phpstan-bot commented Feb 16, 2026

Claude finished @ondrejmirtes's task in 9m 26s —— View job


Tasks

  • Read current implementation and understand the issue
  • Understand how constant native/PHPDoc types work in PHPStan
  • Generate extra tests for constants with native type and PHPDoc type
  • Fix ClassConstantAccessType::getResult() to use declared types for non-final classes
  • Run tests (unable to run locally due to sandbox restrictions — CI will verify)
  • Commit and push

Changes made

src/Type/ClassConstantAccessType.php — Modified getResult() to handle non-final constants on non-final classes when the inner type is still a StaticType:

  • If the constant is final → returns literal value (can't be overridden by subclasses)
  • If the constant has a native type or PHPDoc type → returns getValueType() (which returns the declared type, e.g. string or non-empty-string, not the literal)
  • If the constant has no declared type → returns mixed
  • For concrete types (after resolution via transformStaticType()) → returns the value type as before

The key insight: when a method with @return static::FOO_BAR is called on a concrete ObjectType(FooBar), the StaticType inside ClassConstantAccessType gets replaced with ObjectType(FooBar) via CalledOnTypeUnresolvedMethodPrototypeReflection::transformStaticType()ClassConstantAccessType::traverse(). The StaticType check only fires when the type hasn't been resolved to a concrete class (e.g., in describe() or type comparison contexts).

tests/PHPStan/Analyser/nsrt/bug-13828.php — Added test cases for:

Scenario Parent class result Child class result
Native type (const string) string string
PHPDoc type (@var non-empty-string) non-empty-string non-empty-string
Both native + PHPDoc non-empty-string non-empty-string
Final constant 'foo' (literal) 'foo' (literal)
Untyped constant 'foo' (literal)

function test(FooBar $foo, BarBaz $bar): void
{
assertType("'foo'", $foo->test());
assertType("'bar'", $bar->test());
Copy link
Member

Choose a reason for hiding this comment

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

@phpstan-bot These are still wrong. the constant does not have PHPDoc type, nor native type. The classes are not final. A subclass might change the constant value to something completely else. The right type to use here is mixed.

Copy link
Member

@ondrejmirtes ondrejmirtes left a comment

Choose a reason for hiding this comment

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

@phpstan-bot react to last review pls

@phpstan-bot
Copy link
Collaborator Author

phpstan-bot commented Feb 16, 2026

Claude finished @ondrejmirtes's task in 3m 12s —— View job


Tasks

  • Read current implementation and review feedback
  • Fix ClassConstantAccessType::getResult() — unified logic for non-final classes
  • Fix test expectations: untyped constants on non-final classes → mixed
  • Run tests (unable to run locally due to sandbox restrictions — CI will verify)
  • Commit and push

Changes

src/Type/ClassConstantAccessType.php — Replaced the instanceof StaticType check with a generic approach using getObjectClassReflections(). This fixes the case where static::CONST is resolved to a concrete ObjectType (e.g., FooBar $foo) but the class isn't final — the previous code only checked when the inner type was still a StaticType, missing the resolved case entirely.

Logic:

  • If constant is not final and class is not final → use declared type (native/PHPDoc) or mixed
  • If constant or class is final → use the literal value

tests/PHPStan/Analyser/nsrt/bug-13828.php — Fixed assertions for untyped constants on non-final classes (FooBar, BarBaz, WithUntypedConstant) from literal values ('foo', 'bar') to mixed, since a subclass could override the constant with any value.

Copy link
Member

@ondrejmirtes ondrejmirtes left a comment

Choose a reason for hiding this comment

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

@phpstan-bot We must have entirely the same logic for ClassConstFetch in MutatingScope resolveType, or maybe initializerExprTypeResolver, it deals with the same problems. Please regiew your code and try to change it to match the current type inference logic

@phpstan-bot
Copy link
Collaborator Author

phpstan-bot commented Feb 16, 2026

Claude finished @ondrejmirtes's task in 8m 36s —— View job


Tasks

  • Read current ClassConstantAccessType::getResult() implementation
  • Find and read ClassConstFetch handling in MutatingScope and InitializerExprTypeResolver
  • Compare logic and identify differences
  • Align ClassConstantAccessType::getResult() with existing logic
  • Update tests
  • Run tests (unable to run locally due to sandbox restrictions — CI will verify)
  • Commit and push

Changes

Restructured ClassConstantAccessType::getResult() to match the $isObject path in InitializerExprTypeResolver::getClassConstFetchTypeByReflection() (lines 2506-2531).

src/Type/ClassConstantAccessType.php — The getResult() method now follows the same flow as the existing ClassConstFetch handling:

  1. Extract ClassReflection via getObjectClassReflections() (analogous to $constantClassReflection in InitializerExprTypeResolver)
  2. Handle enum cases → EnumCaseObjectType (matches lines 2471-2474)
  3. Check: class NOT final AND constant NOT final AND no phpDocType AND no nativeType → mixed (matches lines 2507-2514)
  4. Otherwise → getValueType() which returns the declared type for typed constants, or the literal value for untyped constants (matches line 2518)

The condition structure now exactly mirrors the existing code instead of having the inverted nesting from before.

tests/PHPStan/Analyser/nsrt/bug-13828.php — Added two test cases:

  • FinalChild extends FooBar (final class inheriting @return static::FOO_BAR) → resolves to 'baz' (literal)
  • WithFinalTypedConstant (non-final class with final const string + @var non-empty-string) → resolves to non-empty-string

Note: The InitializerExprTypeResolver for final classes uses getType(getValueExpr(), ...) to resolve the literal value even for typed constants. ClassConstantAccessType uses getValueType() instead since it doesn't have service access — this returns the declared type for typed constants (slightly less precise but correct). For untyped constants on final classes, getValueType() already resolves to the literal, so there's no difference in practice for the common case.

Comment on lines 38 to 39
&& $this->type->equals($type->type)
&& $this->constantName === $type->constantName;
Copy link
Contributor

Choose a reason for hiding this comment

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

should do the cheap check first

Comment on lines 68 to 70
if ($constantClassReflection->isEnum() && $constantClassReflection->hasEnumCase($this->constantName)) {
return new Enum\EnumCaseObjectType($constantClassReflection->getName(), $this->constantName);
}
Copy link
Contributor

Choose a reason for hiding this comment

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

maybe this could use $this->type()->getEnumCaseObject()

@ondrejmirtes
Copy link
Member

@phpstan-bot Check Markus's comments

@phpstan-bot
Copy link
Collaborator Author

phpstan-bot commented Feb 16, 2026

Claude finished @ondrejmirtes's task in 3m 49s —— View job


Addressing Markus's review comments

  • Read current ClassConstantAccessType.php
  • Fix line 39: reorder equals() to do cheap string comparison before expensive type comparison
  • Address line 70: evaluate getEnumCaseObject() suggestion
  • Commit and push

Changes

equals() reordering (line 39): Moved the cheap $this->constantName === $type->constantName string comparison before the expensive $this->type->equals($type->type) recursive type comparison, as staabm suggested.

Enum case handling (line 70): I investigated staabm's suggestion to use $this->type->getEnumCaseObject(), but it doesn't apply here. getEnumCaseObject() on Type returns non-null only when the type itself represents a single enum case (e.g., EnumCaseObjectType). Here, $this->type is the class type (e.g., ObjectType('MyEnum')), and we need to check whether a specific constant name on that class is an enum case — getEnumCaseObject() can't do that lookup.

The current pattern ($constantClassReflection->isEnum() && $constantClassReflection->hasEnumCase($this->constantName)) matches the existing code in InitializerExprTypeResolver::getClassConstFetchTypeByReflection() at line 2471. While the check is technically redundant (BetterReflection reports isFinal() = true for enum cases, so they'd fall through to getValueType() correctly), it serves as a direct fast path that avoids going through the full getConstant()InitializerExprTypeResolver resolution chain. I left it as-is to stay consistent with the existing pattern.

@ondrejmirtes
Copy link
Member

@phpstan-bot Please read the failed CI job logs and fix the CI.

@phpstan-bot
Copy link
Collaborator Author

phpstan-bot commented Feb 16, 2026

Claude finished @ondrejmirtes's task in 11m 34s —— View job


Tasks

  • Check CI status and identify failed jobs
  • Read failed job logs and analyze root cause
  • Fix coding standard error: count() fallback global name in ClassConstantAccessType.php
  • Fix ClassConstantAccessType::getResult() — don't return mixed when inner type is StaticType
  • Fix resolveArrayShapeOffsetType — revert to treating static:: like self:: for array shape keys
  • Verify test expectations are correct
  • Commit and push

Root cause

Three CI failures, all stemming from two issues:

1. Coding Standardcount() used without use function count; import in ClassConstantAccessType.php.

2. Test failures (bug-13828, bug-6989) and integration tests — Two fundamental problems:

  • decideType rejects ClassConstantAccessType PHPDoc return type: When ClassConstantAccessType(StaticType(FooBar), 'FOO_BAR') is used as a PHPDoc return type alongside native string, decideType() triggers resolve()getResult(). For untyped constants on non-final classes, getResult() returned mixed. Then string->isSuperTypeOf(mixed) = no, so decideType fell back to the native string type, losing the ClassConstantAccessType before transformStaticType() could replace the inner StaticType with the caller's ObjectType.

  • Array shape keys with ClassConstantAccessType: resolveArrayShapeOffsetType returned a ClassConstantAccessType for static::CONST keys, but ConstantArrayTypeBuilder::setOffsetValueType() needs concrete ConstantStringType values. This broke bug-6989.php (array{key: string} became non-empty-array<string>).

Fixes

ClassConstantAccessType::getResult() — Added early return when the inner type is still a StaticType: returns getValueType() directly (the literal value). This ensures decideType() accepts the PHPDoc type as a subtype of the native return type. After transformStaticType() replaces StaticType with a concrete ObjectType, traverse() creates a new instance whose getResult() applies the full resolution logic (returning mixed for non-final/untyped constants).

TypeNodeResolver::resolveArrayShapeOffsetType() — Reverted to treating static:: the same as self:: since array shape keys need concrete values that can be used as offset types.

ondrejmirtes and others added 5 commits February 16, 2026 11:12
- Added ClassConstantAccessType that wraps StaticType + constant name and
  implements LateResolvableType, deferring resolution until the caller type is known
- Modified TypeNodeResolver::resolveConstTypeNode() and resolveArrayShapeOffsetType()
  to create ClassConstantAccessType when the keyword is 'static' instead of
  resolving eagerly like 'self'
- The StaticType inside ClassConstantAccessType gets replaced with the concrete
  ObjectType during CalledOnTypeUnresolvedMethodPrototypeReflection::transformStaticType(),
  then the constant is resolved on the correct class
- New regression test in tests/PHPStan/Analyser/nsrt/bug-13828.php
When the inner type is still a StaticType (not yet resolved to a concrete
class), use the declared type (native or PHPDoc) instead of the literal
value for non-final constants on non-final classes. For final constants
or final classes, the literal value is preserved since subclasses cannot
override them.

Added test cases for:
- Constants with native type (const string)
- Constants with PHPDoc type (@var non-empty-string)
- Constants with both native and PHPDoc types
- Final constants on non-final classes
- Untyped constants

Co-authored-by: Ondřej Mirtes <ondrejmirtes@users.noreply.github.com>
…non-final classes

When static::CONST is used and the constant has no native type or PHPDoc type,
and neither the class nor the constant is final, return mixed instead of the
literal value — since a subclass could override the constant with any value.

Also unified the logic to use getObjectClassReflections() instead of
instanceof StaticType, so it works for both unresolved StaticType and
resolved ObjectType (e.g., FooBar $foo where $foo could be a subclass).

Co-authored-by: Ondřej Mirtes <ondrejmirtes@users.noreply.github.com>
…solver logic

Restructured getResult() to match the $isObject path in
InitializerExprTypeResolver::getClassConstFetchTypeByReflection():

- Extract ClassReflection first via getObjectClassReflections()
- Add enum case handling (EnumCaseObjectType)
- Match exact condition structure: class not final AND constant not final
  AND no phpDocType AND no nativeType → mixed
- Otherwise use getValueType() (declared type for typed constants,
  literal for untyped constants)

Added test cases for final child class inheriting @return static::CONST
and for final typed constant on non-final class.

Co-authored-by: Ondřej Mirtes <ondrejmirtes@users.noreply.github.com>
…comparison

Co-authored-by: Markus Staab <markus.staab@redaxo.de>
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
@ondrejmirtes ondrejmirtes force-pushed the create-pull-request/patch-gripjte branch from 86aa740 to 486393a Compare February 16, 2026 10:12
Automated fix attempt 1 for CI failures.
Automated fix attempt 2 for CI failures.
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.

Reference to static const behaves as self const

3 participants

Comments