A CPU profile shows where time is spent and not why, and a loop constructing an object per iteration frequently spends its time in the allocator rather than in anything named.
// 400,000 DateTimeImmutable objects, one per row
foreach ($rows as $row) {
$d = new DateTimeImmutable($row['placed_at']);
$buckets[$d->format('Y-m')][] = $row;
}
// the same grouping, with no objects at all
foreach ($rows as $row) {
$buckets[substr($row['placed_at'], 0, 7)][] = $row;
}
Sixty percent of that loop was object construction, which appears in a profile as time in an internal function rather than in the loop — so the profile points at DateTimeImmutable::__construct and the fix is not to call it. memory_get_peak_usage alongside the timing is what makes allocation visible at all. The general shape is that any per-row object in a large loop is worth questioning before anything else is optimised.