skip to content

In PHP, what does DateTimeImmutable::diff() return, why does $interval->format('%d') often give the wrong day count, and how does DatePeriod iterate a range?

level: middleimportance: should knowfreq 30%

answer

  1. diff gives a DateInterval
  2. y, m, d are components
  3. %a is total days
  4. invert for negative intervals
  5. end date excluded unless INCLUDE_END_DATE

basics

~20 s

diff() returns a DateInterval split into years, months, days and time, plus days, the total day count. %d prints only the days component; %a prints the total. DatePeriod steps from start to end, excluding the end unless INCLUDE_END_DATE is set.

solid answer

~40 s

`$a->diff($b)` returns a `DateInterval` whose `y`, `m`, `d`, `h`, `i`, `s` and `f` fields are the difference broken into calendar components, with `invert` set to 1 when `$b` is earlier than `$a`, and `days` holding the total number of full days. So from 1 January to 15 March 2026, `format('%m months %d days')` prints "2 months 14 days" while `format('%a')` prints 73: using `%d` or `->d` as "days between" is the classic bug. `days` is `false` for intervals you build yourself with `new DateInterval('P1M')`, because a month has no fixed length. `DatePeriod($start, $interval, $end)` is iterable and yields a date object per step from the start up to, but not including, the end; `DatePeriod::EXCLUDE_START_DATE` drops the start and `DatePeriod::INCLUDE_END_DATE`, added in PHP 8.2, keeps the end.

code

php · 21 lines
php
<?php
declare(strict_types=1);

$from = new DateTimeImmutable('2026-01-01');
$to = new DateTimeImmutable('2026-03-15');
$gap = $from->diff($to);

echo $gap->format('%m months %d days'), "\n"; // 2 months 14 days
echo $gap->format('%a total days'), "\n";     // 73 total days
var_dump($gap->d, $gap->days);                // int(14) int(73)
echo $to->diff($from)->format('%R%a'), "\n";  // -73

$week = new DatePeriod(
    new DateTimeImmutable('2026-03-01'),
    new DateInterval('P1D'),
    new DateTimeImmutable('2026-03-07'),
    DatePeriod::INCLUDE_END_DATE,           // PHP 8.2+
);
foreach ($week as $day) {
    echo $day->format('D d'), ' ';          // Sun 01 ... Sat 07
}

go deeper

for a junior

Recall that diff() returns a DateInterval, and that %a gives total days while %d gives only the days component.

for a middle

Explain the component fields, invert and days, why days is false for built intervals, and DatePeriod's start-inclusive, end-exclusive default.

for a senior

Review date-difference code for %d and ->d misuse, check period boundaries in reports, and prefer direct comparisons over interval inspection.

for a principal

Standardise how the code base expresses durations, calendar steps and ranges, so reports and billing agree on inclusive or exclusive boundaries.

## diff() returns a broken-down interval `DateTimeInterface::diff(DateTimeInterface $targetObject, bool $absolute = false)` returns a **`DateInterval`**. It does not return a number of seconds or days; it describes the gap the way a calendar would: | Property | Meaning | |---|---| | `y`, `m`, `d` | years, months and days **components** | | `h`, `i`, `s`, `f` | hours, minutes, seconds and fraction of a second | | `invert` | `1` if the target is earlier than the object you called `diff()` on, else `0` | | `days` | the **total** number of full days, only for intervals made by `diff()` | Pass `true` as `$absolute` to force a positive interval. ## The %d bug `DateInterval::format()` has its own format codes, each starting with `%`: - `%y`, `%m`, `%d` print the components; - `%a` prints the total day count, from `days`; - `%R` prints `-` or `+`; `%r` prints `-` only when negative. From 2026-01-01 to 2026-03-15 the interval is 2 months and 14 days, so `%d` prints `14` while `%a` prints `73` (31 days of January, 28 of February and 14 of March). Code that shows "days until expiry" with `%d`, or reads `->d`, is correct only while the gap is under a month, which is why the bug survives testing. ## Intervals you build yourself `new DateInterval('P1Y2M10DT2H30M')` uses the ISO 8601 duration syntax: `P`, then date parts (`Y`, `M`, `D`, `W`), then `T` and time parts (`H`, `M`, `S`). Such an interval has `days === false`, because "one month" has no fixed number of days until it is applied to a specific date. Since PHP 8.3 a malformed specification throws `DateMalformedIntervalStringException`; earlier versions threw a generic `Exception`. Apply an interval with `add()` or `sub()`; on `DateTimeImmutable` both return a new object. ## DatePeriod: iterating a range `DatePeriod` implements `IteratorAggregate`, so it works in `foreach`. Its constructor takes a start date, an interval and either an end date or a number of recurrences. Each step yields a date object of the same kind as the start, a `DateTimeImmutable` if you passed one. The boundaries are the part people get wrong: 1. By default the **start is included** and the **end is excluded**. 2. `DatePeriod::EXCLUDE_START_DATE` skips the start date. 3. `DatePeriod::INCLUDE_END_DATE`, **added in PHP 8.2**, includes the end date when a step lands on it. So a report for "every day from 1 to 7 March" either passes 8 March as the end, or passes 7 March with `INCLUDE_END_DATE`. ## Ranges by count, and reading a period back Instead of an end date, the constructor can take a number of **recurrences**: `new DatePeriod($start, new DateInterval('P1W'), 3)` yields the start plus three further weekly dates, four in all. A period can be inspected with `getStartDate()`, `getEndDate()` (which is `null` for a recurrence-based period) and `getRecurrences()`. An ISO 8601 repeating-interval string such as `R3/2026-03-01T00:00:00Z/P1W` is parsed with the static `DatePeriod::createFromISO8601String()`; passing that string to the constructor is deprecated since PHP 8.4. Because each step is computed by adding the interval to the previous date, a `P1M` period that starts on the 31st shows the same month overflow as `modify('+1 month')`: the steps drift into the following month instead of landing on month ends. ## Practical rules - For a day count, use `->days` or `%a` from a `diff()` result, never `->d` or `%d`. - For a signed difference, read `invert` or print with `%r`/`%R`. - Compare dates with `<` and `>` directly instead of inspecting an interval. - Measuring elapsed time for performance is a different job with a monotonic clock, not date objects. - For calendar steps across months, remember that `P1M` applied from the 31st overflows, exactly like `modify('+1 month')`.

  • Why is $interval->days false for new DateInterval('P1M') but an integer after diff()?
    An interval built from a specification is abstract: one month is 28 to 31 days depending on where it is applied, so PHP cannot give a total. A `diff()` result was computed between two concrete dates, so the engine knows the exact number of full days and stores it in `days`.
  • How would you list every Monday in a quarter with DatePeriod?
    Start from the first Monday on or after the quarter's start with `modify('monday')`, which keeps the date if it already is a Monday and otherwise moves forward to the next one; use `new DateInterval('P1W')` as the step, and pass the day after the quarter's last day as the end (or the last day with `DatePeriod::INCLUDE_END_DATE`).

saying these in an interview costs you the question

  • diff() returns the number of seconds between two dates
  • $interval->d holds the total number of days
  • DatePeriod includes the end date by default
  • A DateInterval built from P1M knows its length in days
  • diff() always returns a positive interval