Skip to content

Fix phpstan/phpstan#14459: Null coalescing operator does not report redundant left operand when always defined#5452

Merged
ondrejmirtes merged 3 commits intophpstan:2.1.xfrom
phpstan-bot:create-pull-request/patch-55b6e8y
Apr 13, 2026
Merged

Fix phpstan/phpstan#14459: Null coalescing operator does not report redundant left operand when always defined#5452
ondrejmirtes merged 3 commits intophpstan:2.1.xfrom
phpstan-bot:create-pull-request/patch-55b6e8y

Conversation

@phpstan-bot
Copy link
Copy Markdown
Collaborator

Summary

PHPStan did not report redundant usage of ?? (and isset()/empty()) when the left-hand side was a promoted readonly property that is always defined and non-nullable. For example, $dto->policyholderId ?? $dto->payerId where policyholderId is public readonly \stdClass was not flagged.

Changes

  • src/Rules/IssetCheck.php: Added condition to not bail early when a property is promoted AND (readonly OR hooked) — these properties are always initialized and cannot be unset, so the isset/coalesce check should proceed to report the redundancy.
  • src/Analyser/MutatingScope.php: Mirrored the same fix in issetCheck() as the codebase convention requires (comment: "mirrored in PHPStan\Rules\IssetCheck").
  • tests/PHPStan/Rules/Variables/data/bug-14459.php: New test data file.
  • tests/PHPStan/Rules/Variables/NullCoalesceRuleTest.php: New test method testBug14459.

Root cause

In IssetCheck::check(), when processing a property fetch that isn't $this->prop, the code checks if the property has an expression type in scope. If not, it checks hasDefaultValue() — promoted readonly properties return false for this (they get their value from the constructor parameter, not a default). This caused the method to return null (no error) even though the property is guaranteed to be initialized and cannot be unset.

The fix adds an additional condition: if the property is promoted AND (readonly OR has hooks), skip the early return and let the error reporting proceed normally.

Test

The regression test covers four scenarios:

  1. Promoted readonly non-nullable (public readonly \stdClass $policyholderId) — expects error: "is not nullable"
  2. Promoted readonly nullable (public readonly ?\stdClass $nullablePolicyholderId) — no error expected (nullable, so ?? is valid)
  3. Promoted non-readonly (public \stdClass $payerId) — no error expected (can be unset())
  4. Non-promoted readonly (public readonly \stdClass $payerId with manual constructor assignment) — no error expected (might be uninitialized)

Fixes phpstan/phpstan#14459

…edundant left operand for promoted readonly properties

- Promoted readonly properties and promoted properties with hooks are always initialized and cannot be unset, so ??, isset(), and empty() with them is redundant when the type is not nullable
- Updated IssetCheck::check() to not bail early for promoted readonly/hooked properties without default values
- Mirrored the same fix in MutatingScope::issetCheck() as required by the codebase convention
- Added regression test in tests/PHPStan/Rules/Variables/data/bug-14459.php covering promoted readonly, nullable, non-readonly, and non-promoted readonly cases
Copy link
Copy Markdown
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.

  1. Hooks don't have a test case. It has to be a separate method with RequiresPhp >= 8.4.
  2. I don't love && !($nativeReflection->isPromoted() && ($nativeReflection->isReadOnly() || $nativeReflection->isHooked())). Rather, it should be && (!$nativeReflection->isPromoted() || (!$nativeReflection->isReadOnly() && !$nativeReflection->isHooked()))

phpstan-bot and others added 2 commits April 13, 2026 15:50
Apply De Morgan's law to improve readability:
- `!($a && ($b || $c))` → `(!$a || (!$b && !$c))`

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Add a separate PHP 8.4 test method covering promoted hooked properties
that are always initialized and cannot be unset.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
@phpstan-bot
Copy link
Copy Markdown
Collaborator Author

Both commits have been pushed. Here's a summary of the changes made to address the review:

  1. Refactored boolean condition (reviewer feedback SoapClient - fixed signature map for SoapClient::__call #2): Changed !($nativeReflection->isPromoted() && ($nativeReflection->isReadOnly() || $nativeReflection->isHooked())) to (!$nativeReflection->isPromoted() || (!$nativeReflection->isReadOnly() && !$nativeReflection->isHooked())) in both src/Rules/IssetCheck.php and src/Analyser/MutatingScope.php. This is logically equivalent (De Morgan's law) but reads more clearly.

  2. Added hooked property test case (reviewer feedback Nicer Markdown #1): Created tests/PHPStan/Rules/Variables/data/bug-14459-hooked.php with promoted hooked property test scenarios and added testBug14459Hooked() test method with #[RequiresPhp('>= 8.4')] in NullCoalesceRuleTest.php. The test verifies that a non-nullable promoted hooked property triggers the "not nullable" error, while a nullable one does not.

Both make tests and make phpstan pass cleanly.

@ondrejmirtes ondrejmirtes merged commit 03834ec into phpstan:2.1.x Apr 13, 2026
655 of 656 checks passed
@ondrejmirtes ondrejmirtes deleted the create-pull-request/patch-55b6e8y branch April 13, 2026 16:04
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.

2 participants