Skip to content

Pass only the right-hand side of ??= as the written expression to PropertyAssignNode and VariableAssignNode - #6943

Open
phpstan-bot wants to merge 3 commits into
phpstan:2.3.xfrom
phpstan-bot:create-pull-request/patch-9m9rb7q
Open

phpstan-bot wants to merge 3 commits into
phpstan:2.3.xfrom
phpstan-bot:create-pull-request/patch-9m9rb7q

Conversation

@phpstan-bot

Copy link
Copy Markdown
Collaborator

Summary

$lvalue ??= $value was checked as if the whole expression's type, nonNull($lvalue) | $value, was written to the target. Only $value is ever written, and only when the target is null. Below level 7 this hid real errors: ?int ??= 'boom' saw int|string, which is a maybe accept. At level 7+ it caused false positives: a stale narrowed type of the property leaked into the checked type. The assign nodes now get only the right-hand side, the same as for the equivalent if (null === $lvalue) { $lvalue = $value; }.

Changes

  • src/Analyser/ExprHandler/AssignHandler.php: new private getWrittenExpr() unwraps AssignOp\Coalesce to its right-hand side. It is used for:

    • PropertyAssignNode for instance and static property fetches, in both the resolved and the fallback branches
    • the SetOffsetValueTypeExpr / SetExistingOffsetValueTypeExpr chain built for offset writes ($this->prop['k'] ??= ...), for both ArrayDimFetch and ExistingArrayDimFetch targets
    • VariableAssignNode for plain variable targets

    Scope updates still use the full ??= result type.

  • turbo-ext/src/AssignHandler.cpp: the same change in the native mirror (getWrittenExpr() plus the same call sites). turbo-ext/src/generated/AssignHandler.h is regenerated because it lists private methods too.

Root cause

Every AssignOp goes through the same read-modify-write path. That path passes the whole $lvalue OP= value expression as the "assigned expression" to the virtual assign nodes. For +=, .= and the others, the expression's value is exactly what gets written. ??= is the one AssignOp where the two differ. The over-wide type reached every consumer of these nodes:

  • TypesAssignedToPropertiesRule: the reported bug, for instance properties, static properties and property offsets
  • ParameterOutAssignedTypeRule: @param-out checks on $p ??= ..., with the same false negative and false positive
  • TooWideTypeCheck (TooWidePropertyTypeRule): the kept old value counted as an assigned type, so "never assigned X" was not reported

Test

  • tests/PHPStan/Rules/Properties/data/bug-15228.php: the issue's reproducer, plus static-property and property-offset variants. It runs with checkUnionTypes both on and off (TypesAssignedToPropertiesRuleTest).
  • tests/PHPStan/Rules/Variables/data/bug-15228.php: the @param-out analog (ParameterOutAssignedTypeRuleTest).
  • tests/PHPStan/Rules/TooWideTypehints/data/bug-15228.php: the too-wide-property analog (TooWidePropertyTypeRuleTest).

All of these fail without the fix and pass with it. The full test suite passes with and without the turbo extension loaded, and analysis output is identical in both modes.

Fixes phpstan/phpstan#15228

🤖 Generated with Claude Code

…PropertyAssignNode` and `VariableAssignNode`

- `$lvalue ??= value` writes only `value`, but AssignHandler handed the whole
  `AssignOp\Coalesce` node to PropertyAssignNode/VariableAssignNode, whose
  type is `nonNull($lvalue)|value`. The rules checking the write therefore saw
  a widened type.
- Add AssignHandler::getWrittenExpr() and use it for every PropertyAssignNode
  (instance + static property, resolved + fallback branches), for the
  Set*OffsetValueTypeExpr chain built for offset writes on properties
  (ArrayDimFetch and ExistingArrayDimFetch), and for the VariableAssignNode
  of plain variable targets. Scope updates still use the full `??=` type.
- Mirror the change in turbo-ext/src/AssignHandler.cpp and regenerate
  turbo-ext/src/generated/AssignHandler.h.
- Fixes in TypesAssignedToPropertiesRule: false negative below level 7
  (`?int` ??= 'boom' not reported) and false positive at level 7+ (stale
  narrowed property type merged into the reported type), for instance
  properties, static properties and property offsets.
- Same root cause fixed for ParameterOutAssignedTypeRule (`@param-out`
  checks on `$p ??= ...`) and TooWidePropertyTypeRule (the kept old value
  hid "never assigned X" reports).
@VincentLanglet
VincentLanglet requested a review from staabm November 4, 2026 09:57
Comment on lines +2060 to +2062
if ($assignedExpr instanceof Expr\AssignOp\Coalesce) {
return $assignedExpr->expr;
}

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.

do we have the same problem with other assignment operators?

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

No, the other assignment operators don't have this problem, so the code stays as it is. I added a test to show it and pushed it as cded8d547.

Why only ??= is affected: for $x OP= $v with any other operator, the value of the whole expression is exactly what gets written to $x. Passing that expression to PropertyAssignNode/VariableAssignNode is already correct. ??= is the only one where the two differ: the expression's type, nonNull($x) | $v, includes the old value, which is kept and never written.

Test: a new OtherAssignOpsDemo class in tests/PHPStan/Rules/Properties/data/bug-15228.php puts each compound assignment next to its expanded form:

  • $this->a .= 'x' and $this->a = $this->a . 'x' both report "(int|null) does not accept string".
  • $this->r += 5 and $this->r = $this->r + 5 both report "(int<0, 10>) does not accept int<5, 15>".

The errors match with checkUnionTypes on and off. With it off, the += pair isn't reported in either form, so they still agree. make tests and make phpstan both pass.

One unrelated thing came up while checking: $this->i /= $x on an int property, which gives int|float, isn't reported. The expanded $this->i = $this->i / $x isn't reported either, so it isn't specific to compound assignment and is outside this PR.

…their expanded form

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@staabm

staabm commented Nov 4, 2026

Copy link
Copy Markdown
Contributor

in #6860 there is a competing PR which also adjusts AssignOpHandler

@VincentLanglet VincentLanglet self-assigned this Nov 4, 2026

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

Does other Handler need a similar fix ? Like AssignOpHandler ?

…tRule

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

Copy link
Copy Markdown
Collaborator Author

AssignOpHandler doesn't need its own fix, but one rule did have the same bug. I fixed it in a new commit, d81c4c1a0, and pushed it.

AssignOpHandler: it never creates PropertyAssignNode or VariableAssignNode itself. It builds the result for the whole $lvalue OP= value expression and passes it to AssignHandler::applyWrite(), which is where the nodes are created. So getWrittenExpr() in AssignHandler already covers every ??= coming from AssignOpHandler, including the native C++ mirror. The other places that create these nodes, ForeachHandler and TryCatchHandler, never handle ??=.

OffsetAccessValueAssignmentRule had the same bug. For any AssignOp it checked $scope->getType($node), the type of the whole expression. For ??= that type includes the old non-null value, which is kept and never written. Example: an ArrayAccess class whose offsetGet() returns ?string and whose offsetSet() takes int. $foo['x'] ??= 5 was wrongly reported as "does not accept int|string", while the expanded if (null === $foo['x']) { $foo['x'] = 5; } was not reported. The rule now checks only the right-hand side for ??=, as it already does for = and =&.

  • The new test, tests/PHPStan/Rules/Arrays/data/offset-access-value-assignment-coalesce.php, has a valid and an invalid case for both ??= and its if form. It fails without the fix (the extra error on line 35) and passes with it.
  • Rules have no native C++ version, so nothing changes in turbo-ext/.

Rules that also listen to AssignOp but are fine:

  • OffsetAccessAssignOpRule only checks the offset type, not the value.
  • InvalidBinaryOperationRule doesn't handle ??=.
  • ClassStatementsGatherer already handles ??= on its own.

make tests and make phpstan both pass.

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.

False positive and false negative with null coalescing assignment operator (??=)

3 participants