Skip to content

Fix phpstan/phpstan#11073: Nullsafe operator chaining#5407

Open
phpstan-bot wants to merge 3 commits intophpstan:2.1.xfrom
phpstan-bot:create-pull-request/patch-nsdr3no
Open

Fix phpstan/phpstan#11073: Nullsafe operator chaining#5407
phpstan-bot wants to merge 3 commits intophpstan:2.1.xfrom
phpstan-bot:create-pull-request/patch-nsdr3no

Conversation

@phpstan-bot
Copy link
Copy Markdown
Collaborator

Summary

Fixes a false positive where PHPStan reported "Cannot call method setTime() on DateTimeImmutable|null" when using nullsafe operator chaining like $date?->modify('+1 year')->setTime(23, 59, 59). The nullsafe operator (?->) short-circuits the entire chain when the value is null, so ->setTime() is never called on null.

Changes

  • Fixed src/Type/Php/DateTimeModifyReturnTypeExtension.php to strip null from the callee type before returning it as the return type of modify()
  • Updated assertion in tests/PHPStan/Analyser/nsrt/date-format.php (null was incorrectly part of modify() return type)
  • Added rule test in tests/PHPStan/Rules/Methods/data/bug-11073.php and tests/PHPStan/Rules/Methods/CallMethodsRuleTest.php
  • Added type inference test in tests/PHPStan/Analyser/nsrt/bug-11073.php

Root cause

DateTimeModifyReturnTypeExtension::getTypeFromMethodCall() returned $scope->getType($methodCall->var) as the return type when all argument strings are valid modifiers. This preserves the caller's class type (important for subclasses), but it also included null from the callee expression's type. When NullsafeOperatorHelper::getNullsafeShortcircuitedExprRespectingScope converts a NullsafeMethodCall to a regular MethodCall for type checking, the scope still has the underlying variable as nullable. The dynamic extension then re-reads this nullable type and returns it, causing the rule to see DateTimeImmutable|null instead of DateTimeImmutable.

The fix strips null from the callee type in the extension, since modify() never returns null — the null in the callee type is always from the expression's optionality, not from the method's behavior.

Test

  • Rule test verifies that $date?->modify('+1 year')->setTime(23, 59, 59) produces no errors when $date is ?DateTimeImmutable
  • NSRT test verifies the full chain's type is correctly inferred as DateTimeImmutable|null

Fixes phpstan/phpstan#11073

- Fixed DateTimeModifyReturnTypeExtension to strip null from callee type
  before returning it as the method's return type
- The extension was returning $scope->getType($methodCall->var) which
  includes null from the nullsafe operator, causing "Cannot call method
  on DateTimeImmutable|null" false positive for chained calls like
  $date?->modify('+1 year')->setTime(23, 59, 59)
- Updated existing date-format.php test assertion (null was incorrectly
  expected in modify() return type)
- Added regression tests for the nullsafe chaining scenario
} elseif ($hasDateTime) {
return $scope->getType($methodCall->var);
$callerType = $scope->getType($methodCall->var);
if (TypeCombinator::containsNull($callerType)) {
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.

Remove the check containsNull since it's already in removeNull

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.

Done. Removed the redundant containsNull check as requested by VincentLanglet — TypeCombinator::removeNull() is a no-op when there's no null in the type, so the guard was unnecessary. All tests and static analysis pass.


function (?\DateTimeImmutable $d): void {
assertType('DateTimeImmutable|null', $d->modify('+1 day'));
assertType('DateTimeImmutable', $d->modify('+1 day'));
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.

Add an assertType on $d?->modify('+1 day')

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.

Tests background task confirmed complete as well — 11733 tests passing. All done.

phpstan-bot and others added 2 commits April 5, 2026 22:57
TypeCombinator::removeNull() already handles the case where the type
does not contain null, making the containsNull guard unnecessary.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
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