Skip to content

Infer numeric-string for DateInterval::format('%a') when interval comes from diff()#5674

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

Infer numeric-string for DateInterval::format('%a') when interval comes from diff()#5674
phpstan-bot wants to merge 3 commits into
phpstan:2.1.xfrom
phpstan-bot:create-pull-request/patch-amhn40y

Conversation

@phpstan-bot
Copy link
Copy Markdown
Collaborator

Summary

DateInterval::format('%a') was incorrectly inferred as non-empty-string (not numeric) even when the DateInterval came from DateTimeInterface::diff(). This caused false positives when using format('%a') in arithmetic operations like $interval->format('%a') * 60.

The root cause: the extension evaluated format specifiers against new DateInterval('P0D'), where %a returns (unknown) because days is false. But after diff(), days is narrowed to int and %a returns a numeric string.

Changes

  • Extracted shared type inference logic from DateIntervalFormatDynamicReturnTypeExtension into a new DateIntervalFormatReturnTypeHelper (src/Type/Php/DateIntervalFormatReturnTypeHelper.php)
  • The helper checks if the interval's days property is int (indicating it came from diff() or date_diff())
  • When days is int, a diff-created DateInterval is used for evaluating %a, producing correct numeric-string inference
  • Added DateIntervalFormatFunctionReturnTypeExtension (src/Type/Php/DateIntervalFormatFunctionReturnTypeExtension.php) for the procedural date_interval_format() function, which previously had no dynamic return type inference at all
  • Simplified DateIntervalFormatDynamicReturnTypeExtension to delegate to the helper

Root cause

The DateIntervalFormatDynamicReturnTypeExtension tested all format specifiers against a fresh new DateInterval('P0D'). For %a (total days), this produces (unknown) since the days property is false when the interval was not created via diff(). However, PHPStan's stubs already narrow diff() to return DateInterval&object{days:int}, which means the %a specifier will always produce a numeric string in that context.

Test

  • Added tests/PHPStan/Analyser/nsrt/bug-1452.php testing:
    • DateTimeImmutable::diff() + format('%a')numeric-string
    • DateTime::diff() + format('%a')numeric-string
    • DateTimeInterface::diff() + format('%a')numeric-string
    • date_diff() + format('%a')numeric-string
    • %R%a combination → numeric-string (since +0 is numeric)
    • Plain DateInterval (not from diff) + format('%a')non-empty-string (not numeric)
    • date_interval_format() function with both diff and non-diff intervals
    • Arithmetic on format('%a') result produces float|int (no error)

Fixes phpstan/phpstan#1452

… comes from `diff()`

- Extract shared format type inference logic into DateIntervalFormatReturnTypeHelper
- Check if the receiver's `days` property is narrowed to `int` (indicating the
  interval came from DateTimeInterface::diff() or date_diff())
- When `days` is `int`, use a diff-created DateInterval for evaluating `%a` format
  specifier, which produces a numeric string instead of "(unknown)"
- Add DateIntervalFormatFunctionReturnTypeExtension for the procedural
  date_interval_format() function, which previously had no type inference
Comment thread src/Type/Php/DateIntervalFormatReturnTypeHelper.php Outdated
Use a single $dateInterval selected based on $daysIsInt instead of
maintaining two separate intervals and checking str_contains for %a.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Comment thread src/Type/Php/DateIntervalFormatReturnTypeHelper.php Outdated
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
@VincentLanglet VincentLanglet requested a review from staabm May 16, 2026 13:05
@VincentLanglet VincentLanglet self-assigned this May 16, 2026
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