php 68 lines · 9 steps

Bucketing spending by time period in Laravel

A report class groups an account's outgoing transactions into pre-seeded time buckets, tallying totals and per-category spend.

Explained by highlit
1<?php
2 
3namespace App\Reports;
4 
5use App\Models\Transaction;
6use Carbon\CarbonImmutable;
7use Carbon\CarbonPeriod;
8use Illuminate\Support\Collection;
9 
10final class SpendingReport
11{
12 public function __construct(
13 private readonly int $accountId,
14 private readonly CarbonImmutable $from,
15 private readonly CarbonImmutable $to,
16 private readonly string $interval = 'month',
17 ) {
18 }
19 
20 public function build(): Collection
21 {
22 $buckets = $this->emptyBuckets();
23 
24 Transaction::query()
25 ->where('account_id', $this->accountId)
26 ->where('amount', '<', 0)
27 ->whereBetween('posted_at', [$this->from, $this->to])
28 ->get(['amount', 'category', 'posted_at'])
29 ->each(function (Transaction $tx) use ($buckets): void {
30 $key = $this->bucketKey($tx->posted_at);
31 
32 $bucket = $buckets->get($key);
33 $bucket['total'] += abs($tx->amount);
34 $bucket['count']++;
35 $bucket['by_category'][$tx->category] =
36 ($bucket['by_category'][$tx->category] ?? 0) + abs($tx->amount);
37 
38 $buckets->put($key, $bucket);
39 });
40 
41 return $buckets->values();
42 }
43 
44 private function emptyBuckets(): Collection
45 {
46 $step = "1 {$this->interval}";
47 
48 return collect(CarbonPeriod::create($this->from, $step, $this->to))
49 ->mapWithKeys(fn (CarbonImmutable $start): array => [
50 $this->bucketKey($start) => [
51 'period' => $start->format('Y-m'),
52 'starts_at' => $start->toDateString(),
53 'total' => 0.0,
54 'count' => 0,
55 'by_category' => [],
56 ],
57 ]);
58 }
59 
60 private function bucketKey(CarbonImmutable $date): string
61 {
62 return match ($this->interval) {
63 'week' => $date->format('o-\WW'),
64 'year' => $date->format('Y'),
65 default => $date->format('Y-m'),
66 };
67 }
68}
01 / 01
STEP 01

Walkthrough

Space play step click any line
Three takeaways
  1. 1Pre-seeding empty buckets for every period guarantees gaps show up as zeros instead of missing rows.
  2. 2A single keying function shared between seeding and aggregation keeps both sides aligned by construction.
  3. 3Pushing filters into the query and folding results in one pass keeps the aggregation lean and readable.

Related explainers

Share this explainer

Here's the card — post it anywhere.

Bucketing spending by time period in Laravel — share card
Made with highlit — turn any snippet into a walkthrough like this in about a minute.
Explain your code