How I Cut Report Generation from 30s to 2s in Laravel
A practical walkthrough of how Redis caching, Laravel Queues, and careful query optimisation transformed a sluggish ERP reporting module into a near-instant one.
Vincent Ndegwa
I inherited a Laravel reporting module that looked fine in code review but failed in real life. The monthly report endpoint took 32 seconds on a good day. At month end, when many users hit the button together, request queues piled up and CPU usage climbed into panic territory.
I did not start with rewrites. I started with measurement.
Step 1: Profile Before You Touch Anything
Laravel Telescope showed the endpoint was firing 47 SQL queries, many inside loops. A few were scanning large transaction tables with weak indexes.
foreach ($departments as $department) {
$department->transactions; // one query per row
}
This pattern was the main reason latency exploded under load.
Step 2: Remove N+1 and Fix Indexing
I switched to eager loading and added a composite index on high selectivity columns used in filters.
$departments = Department::with([
'transactions' => fn ($q) => $q->whereBetween('created_at', [$start, $end])
])->get();
CREATE INDEX idx_txn_department_created_at
ON transactions (department_id, created_at);
Result: query phase dropped from around 18 seconds to around 6 seconds.
Step 3: Move Heavy Generation to Queues
PDF generation and aggregation logic were running in the request cycle. I moved the expensive work to a queued job and made the endpoint return immediately.
public function generate(ReportRequest $request)
{
$job = GenerateReportJob::dispatch($request->validated());
return response()->json([
'status' => 'queued',
'job_id' => $job->id,
]);
}
The frontend now polls a lightweight status endpoint. Users get fast feedback instead of watching a spinner freeze.
Step 4: Cache Expensive Aggregates in Redis
Some report widgets are read hundreds of times but change only when new transactions land. Perfect cache candidates.
$summary = Cache::tags(['reports', 'department:' . $departmentId])
->remember('month:' . $month, now()->addMinutes(15), function () use ($departmentId, $month) {
return Transaction::where('department_id', $departmentId)
->whereMonth('created_at', $month)
->selectRaw('SUM(amount) AS total, COUNT(*) AS count')
->first();
});
On transaction writes, I invalidate only related tags so fresh data appears quickly without nuking the whole cache.
Step 5: Guardrails for Scale
Performance wins disappear without guardrails. I added:
- slow query logging
- a threshold alert for report queue wait time
- basic load tests in CI before each release
Outcome
| Metric | Before | After |
|---|---|---|
| Report generation | 32s | 1.8s |
| API response time | 32s | < 200ms |
| Month end CPU | ~90% | ~20% |
The important lesson: serious performance tuning is usually less about heroics and more about disciplined bottleneck removal. Measure, target one class of failure at a time, and verify with numbers.