DatePeriod: The Loop You Were Hand-Rolling for Calendars
The hand-written version of "loop over each day" is a while loop that clones a DateTime, adds a day, and - in the versions I keep finding in real code - either mutates a shared object, skips the last day, or double-counts across a DST change. PHP has had the purpose-built tool since 5.3:
$period = new DatePeriod(
new DateTimeImmutable('2026-07-01'),
new DateInterval('P1D'), // step: 1 day
new DateTimeImmutable('2026-08-01'), // end (exclusive by default)
);
foreach ($period as $day) {
echo $day->format('D Y-m-d') . PHP_EOL; // each $day is a fresh immutable object
}
Three arguments - start, step, end - and iteration just works. Because the start was a DateTimeImmutable, every yielded date is immutable too: no aliasing bugs, nothing to clone defensively.
The end-exclusivity decision
By default the end date is not included - half-open ranges, like array slices, which compose without overlap (July's range ends where August's begins). When you want it included, say so explicitly (PHP 8.2+):
new DatePeriod($start, $interval, $end, DatePeriod::INCLUDE_END_DATE);
Before 8.2, the idiom was extending $end by one step. Either way, the decision is now visible in the code instead of buried in a <= vs < comparison.
Recurrences: the other constructor
// "Next 12 billing dates, monthly from signup"
$cycles = new DatePeriod($signupDate, new DateInterval('P1M'), 12);
// "Every Monday for 8 weeks" - anchor with a date phrase first
$start = new DateTimeImmutable('next monday');
$standups = new DatePeriod($start, new DateInterval('P1W'), 8);
Count instead of end date. Note the monthly-billing classic hiding here: P1M from Jan 31 overflows into March (the same month-arithmetic trap as modify('+1 month')) - for billing anchored to month-ends, generate from 'first day of' anchors and clamp the day.
Interval spellings worth memorizing
P1D, P1W, P1M, P1Y for calendar steps; PT1H, PT30M for time; composites like P1DT12H. The P/T split (Period/Time) is the only syntax hurdle. For business-day iteration, DatePeriod has no skip rule - iterate days and filter (if ($day->format('N') >= 6) continue;), which stays honest about holidays needing a real calendar anyway.
// Calendar-month grid, padded to full weeks - DatePeriod does the heavy lifting
$first = new DateTimeImmutable('first day of this month midnight');
$gridStart = $first->modify('monday this week');
$gridEnd = $first->modify('last day of this month')->modify('sunday this week');
$weeks = iterator_to_array(new DatePeriod($gridStart, new DateInterval('P1D'),
$gridEnd, DatePeriod::INCLUDE_END_DATE));
That's a month-view calendar's date grid in four lines - the kind of code that used to be a 40-line function with three off-by-one fixes in its git history. Ranges are data; iterate them like data.
Full-stack web developer sharing practical tutorials and building tools that ship.
Got something on your mind?
My inbox is open - no forms disappearing into the void here.
- Just say hello Found a tutorial useful? Spotted a mistake? Tell me.
- Hire me for a project Have something custom in mind? Let's talk scope and timelines.
- Product support Bought something here? I'll help you get it running.
I usually reply within 1-2 business days.