php 124 lines · 4 tabs

Cache Expensive Dashboard Aggregates and Bust Them on Write in Laravel

Shared by codesnips Jul 2026
4 tabs
<?php

namespace App\Services;

use App\Models\Order;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\DB;

class DashboardMetrics
{
    private const CACHE_VERSION = 'v3';
    private const TTL = 900; // 15 minutes

    public static function tag(int $teamId): string
    {
        return "metrics:{$teamId}";
    }

    public function revenueSummary(int $teamId, string $from, string $to): array
    {
        $key = sprintf('%s:revenue:%d:%s:%s', self::CACHE_VERSION, $teamId, $from, $to);

        return Cache::tags([self::tag($teamId)])->remember($key, self::TTL, function () use ($teamId, $from, $to) {
            return Order::query()
                ->where('team_id', $teamId)
                ->whereBetween('created_at', [$from, $to])
                ->select(DB::raw('DATE(created_at) as day'))
                ->selectRaw('COUNT(*) as orders')
                ->selectRaw('SUM(total_cents) as revenue_cents')
                ->groupBy('day')
                ->orderBy('day')
                ->get()
                ->toArray();
        });
    }

    public static function forget(int $teamId): void
    {
        Cache::tags([self::tag($teamId)])->flush();
    }
}
4 files · php Explain with highlit

This snippet shows the read-through cache pattern applied to an expensive analytics query in Laravel, together with the write-side invalidation that keeps it from going stale. The core idea is to compute a costly aggregate once, store it under a stable key, and serve every subsequent request from memory until an underlying row changes.

In DashboardMetrics service, the revenueSummary method wraps the heavy query in Cache::tags(...)->remember(...). The key is namespaced per team and per date range so different tenants and filters never collide, and a version segment (CACHE_VERSION) allows a global flush by bumping a constant during deploys. The closure that produces the value is only executed on a miss; on a hit the grouped selectRaw aggregation over orders is skipped entirely. Tagging the entry with metrics:{teamId} is what makes targeted invalidation possible — a single Cache::tags(...)->flush() clears every range and metric for that team without touching other tenants. Note that tagged caches require a store like Redis or Memcached; the file-based store does not support them.

The trade-off of any cache is staleness, so OrderObserver closes the loop. Laravel fires the observer's saved and deleted hooks on every create, update, and delete, and each one calls DashboardMetrics::forget for the affected team_id. Because invalidation is keyed on a tag rather than a specific range, the code does not need to know which date buckets a given order affects — it drops them all and lets the next read recompute. This favors correctness and simplicity over surgical precision.

In AppServiceProvider, Order::observe(OrderObserver::class) registers the hook so the busting happens automatically wherever orders are written, including inside jobs and console commands. DashboardController simply asks the service for the summary; it neither knows nor cares whether the value came from cache. A subtle pitfall worth noting: observers do not fire for bulk update/delete queries that bypass Eloquent, so those code paths must call forget explicitly. This pattern fits dashboards where reads vastly outnumber writes and a brief recompute on the next request is acceptable.


Related snips

Share this code

Here's the card — post it anywhere.

Cache Expensive Dashboard Aggregates and Bust Them on Write in Laravel — share card
Link copied