Normalize constant-string member access ($obj->{'n'}) to bareword form when printing expression keys#5916
Merged
staabm merged 1 commit intoJun 22, 2026
Conversation
…orm when printing expression keys
- Override `pObjectProperty()` in `PHPStan\Node\Printer\Printer` so that a
curly-brace member name that is a constant string literal forming a valid PHP
label (e.g. `$obj->{'n'}`, `$obj->{'n'}()`, `Foo::${'s'}`) is printed the same
as its bareword equivalent (`$obj->n`, `$obj->n()`, `Foo::$s`).
- Because `MutatingScope::getNodeKey()` keys expression types on this printed
form, the two syntaxes now share an expression key. Type narrowing, dead-code
detection, impurity invalidation and remembered values are therefore applied
consistently regardless of which syntax is used to access the same member.
- Covers property fetch, nullsafe property fetch, static property fetch, and
method/static-method calls, since `pObjectProperty()` backs all of them.
- Probed AST-level normalization (rewriting the `String_` name node to an
`Identifier`) as a more aggressive alternative: rejected because it changes
method-call side-effect analysis (e.g. `$this->{'increment'}()` is currently
treated as an unknown call that invalidates `$this`, which bug-3831 relies on).
The key-only fix keeps that behavior intact while fixing the reported narrowing
and dead-code equivalence.
7d9e164 to
f5b90f7
Compare
staabm
approved these changes
Jun 22, 2026
VincentLanglet
approved these changes
Jun 22, 2026
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.
Summary
In PHP
$obj->{'n'}and$obj->naccess the exact same property (and likewise$obj->{'n'}()≡$obj->n(),Foo::${'s'}≡Foo::$s), but PHPStan treatedthem as two distinct expressions in some contexts. This produced false negatives:
type narrowing performed through one syntax was not seen through the other, and
dead code / always-false comparisons were not reported when the two syntaxes were
mixed.
The fix teaches PHPStan that these are the same expression by normalizing how the
member name is printed, which is what backs the scope's expression keys.
Changes
src/Node/Printer/Printer.php: overridepObjectProperty()so that acurly-brace member name which is a constant string literal matching the PHP
label grammar (
/^[a-zA-Z_\x80-\xff][a-zA-Z0-9_\x80-\xff]*$/) is printed asthe bareword name instead of
{'...'}. Non-label strings (e.g.{'complex-name'},{'0'}) and dynamic names ({$name},{'a' . $b}) keep the curly form.tests/PHPStan/Analyser/nsrt/bug-14847.php: type-narrowing regression test.tests/PHPStan/Rules/Comparison/data/bug-14847.php+StrictComparisonOfDifferentTypesRuleTest::testBug14847(): dead-code regression test.pObjectProperty()is the single helper nikic's printer uses for propertyfetches, nullsafe property fetches, static property fetches, and method / static
method calls, so the normalization covers that whole family at once.
Root cause
MutatingScope::getNodeKey()keys an expression's tracked type on thepretty-printed form returned by
ExprPrinter/Printer. The standard printerrenders
$obj->{'n'}and$obj->ndifferently, so narrowing stored under thekey
$obj->nwas invisible when reading$obj->{'n'}(and vice versa). Printingboth as
$obj->nmakes the keys identical, so every key-based mechanism(narrowing,
identical.alwaysFalse/alwaysTruedead-code detection, impurityinvalidation, remembered impure return values) now treats them as one member.
Analogous cases probed
Foo::${'s'}↔Foo::$s) and nullsafe propertyfetch (
$obj?->{'n'}↔$obj?->n): same root cause, fixed by the same change(they all go through
pObjectProperty()). Covered by the nsrt test.$obj->{'check'}()↔$obj->check()): theexpression-key part is fixed by this change. A deeper discrepancy remains where
the method-call handlers branch on
$expr->name instanceof Identifierand skipreflection (assertions, conditional return types, side-effect tracking) for a
string name. I deliberately did not force these through reflection: the
current "string-named call ⇒ treat as unknown call ⇒ invalidate the receiver"
behavior is what
tests/.../nsrt/bug-3831.phpdepends on, and rewriting theString_name to anIdentifierat the AST level regresses that side-effectanalysis. Fixing method-call reflection for dynamic-but-constant names is a
larger, separate concern.
Test
tests/PHPStan/Analyser/nsrt/bug-14847.phpasserts narrowing flows in bothdirections for instance properties, static properties and nullsafe access. It
fails before the fix (the
{'n'} !== null⇒$obj->ndirection infersstring|null) and passes after.tests/PHPStan/Rules/Comparison/data/bug-14847.phpchecks that an always-false=== nullcomparison is reported when the guard and the comparison usedifferent syntaxes; the curly-then-bareword direction is missed before the fix.
Fixes phpstan/phpstan#14847