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?
answer
- diff gives a DateInterval
- y, m, d are components
- %a is total days
- invert for negative intervals
- end date excluded unless INCLUDE_END_DATE
basics
~20 sdiff() 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
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
Recall that diff() returns a DateInterval, and that %a gives total days while %d gives only the days component.
Explain the component fields, invert and days, why days is false for built intervals, and DatePeriod's start-inclusive, end-exclusive default.
Review date-difference code for %d and ->d misuse, check period boundaries in reports, and prefer direct comparisons over interval inspection.
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