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
‹ swipe to step through ›
Walkthrough
Space play
←→ step
click any line
Three takeaways
- 1Pre-seeding empty buckets for every period guarantees gaps show up as zeros instead of missing rows.
- 2A single keying function shared between seeding and aggregation keeps both sides aligned by construction.
- 3Pushing filters into the query and folding results in one pass keeps the aggregation lean and readable.
Related explainers
php
<?php class NameParser {
Parsing a full name into components in PHP
string-parsing
arrays
normalization
Intermediate
8 steps
php
<?php namespace App\Services\Checkout;
Validating coupons with Laravel's Pipeline
pipeline
chain of responsibility
transactions
Intermediate
7 steps
php
<?php namespace App\Services;
How a password strength validator works in PHP
validation
regular-expressions
data-driven
Intermediate
8 steps
ruby
class LogAggregator BUCKET_FORMAT = "%Y-%m-%dT%H:%M" def initialize(entries)
Bucketing log entries by the minute in Ruby
aggregation
hashing
enumerable
Intermediate
5 steps
ruby
class WeeklySignupsReport DEFAULT_WEEKS = 12 def initialize(weeks: DEFAULT_WEEKS, source: User.all)
Building a weekly signups report in Rails
service object
aggregation
group by
Intermediate
7 steps
php
<?php namespace App\Services;
Building a cached daily leaderboard in Laravel
caching
aggregation
eager-loading
Intermediate
9 steps
Share this explainer
Here's the card — post it anywhere.
Made with highlit — turn any snippet into a walkthrough like this in about a minute.
Explain your code
Embed this explainer
Drop the interactive walkthrough into a blog or docs. Views never cost a credit.
<iframe src="https://highlit.co/explainers/bucketing-spending-by-time-period-in-laravel-explained-php-b10d/embed?autoplay=1" width="100%" height="520" loading="lazy" style="border:0"></iframe>
Autoplay is on by default — add ?autoplay=0 to start paused.