Performance work has a reputation for being glamorous — the heroic "we cut latency by 80%" story. Most days it's not that. Most days it's a janitorial pass: you go looking for the queries you're firing without realizing it, and you quietly delete them. That was today. One sustained sweep across an app and the package that backs it, chasing the same theme everywhere: stop asking the database for things you don't use.
Let me walk through the patterns, because they generalize to any Laravel app of a certain age.
First, make the invisible visible
You can't fix N+1s you can't see. The first move was wiring up an N+1 detector in the local/dev environment only — beyondcode/laravel-query-detector. It hooks into the request lifecycle, watches your Eloquent relationship loads, and screams (in the console, or as an exception if you want it strict) when it spots the classic loop-and-lazy-load pattern.
The "dev-only" part matters. You never want a query detector running in production — it adds overhead and it's a developer aid, not a runtime guard. So it goes in behind an environment check, registered only when the app isn't in production:
public function register(): void
{
if ($this->app->environment('local', 'testing')) {
$this->app->register(\BeyondCode\QueryDetector\QueryDetectorServiceProvider::class);
}
}
Think of it like a smoke detector you only arm while you're cooking. It's noisy by design — that's the point. The noise is a to-do list.
Eager loads you don't actually use are just N+1s wearing a disguise
Here's the counterintuitive one. We're all trained to fix N+1s by adding with(). But the opposite bug is just as common and almost never gets caught: you eager-load a relationship, and then... never touch it in the view.
Index screens are the worst offenders. Someone builds a listing, eager-loads creator and approver so the table can show names, then a redesign drops those columns — but the with(['creator', 'approver']) stays. Now every page load hydrates relationships nobody renders. It's not an N+1 (you're not looping), but it's the same disease: queries you're paying for and throwing away.
The fix is boring and satisfying — you read the Blade, you read the component, and you delete eager loads with no consumer:
// Before: hydrating relationships the view no longer renders
$services = Service::query()
->with(['creator', 'approver'])
->latest()
->paginate();
// After: load only what the table actually shows
$services = Service::query()
->latest()
->paginate();
Across a few index components — services, catalogue, subscriptions, consumers, the admin users list — that single discipline removed a surprising number of round-trips. The lesson: an eager load is a claim that "I will use this." Audit those claims periodically, because views change faster than the queries behind them.
Compute once per request: memoization
Two of today's wins were the same shape: a value that's stable for the duration of a request, but recomputed every time it's asked for.
The first was a default-connection resolver — a method that figured out which database connection to use, doing a little work each call, and getting called all over the place in a single request. The second was a sidebar showing an unread-notification count, recounted on every component render.
Neither needs to change mid-request. So memoize:
protected ?string $defaultConnection = null;
public function getDefaultConnectionName(): string
{
return $this->defaultConnection ??= $this->resolveDefaultConnection();
}
The ??= null-coalescing assignment is the whole trick: compute on first call, reuse the cached value for the rest of the request. For per-request memoization on a singleton or a scoped resolver, that's all you need. For values stable across requests (not just within one), reach for the cache instead — which brings us to the big one.
The dashboard that fired ~20 queries to show a handful of numbers
Dashboards are query traps. You've got a row of stat cards — total this, active that, pending the other — and the lazy implementation is one Model::count() per card. Six cards across a few tables and suddenly your landing page is the heaviest read in the app, run on every login.
Two changes here, stacked:
Collapse counts per table. Instead of N separate
count()queries against the same table, get all the aggregates you need in a single grouped query. One trip to the database per table, not one per metric.Cache the result. Dashboard stats don't need to be real-time to the second. Wrapping them in a short-lived cache means the first visitor of the window pays for the query and everyone after rides the cache:
public function stats(): array
{
return Cache::remember('dashboard.stats', now()->addMinutes(5), function () {
return [
'services' => $this->serviceCounts(), // one grouped query
'consumers' => $this->consumerCounts(), // one grouped query
// ...
];
});
}
The trade-off is honesty: a cached dashboard is slightly stale. For an ops overview that's almost always the right call — nobody's making a decision on whether the active count is 1,204 or 1,205. If a number genuinely must be live, leave that one out of the cache. Don't cache-blanket reflexively; cache where staleness is acceptable, which is most of a dashboard.
A quick word on the database connection itself
The other half of the day was about the connection, not the queries. Two ideas worth flagging without going deep: optionally enabling persistent database connections (so you're not paying TCP/handshake setup on every request to a remote database), and adding a small connection-latency diagnosis so you can actually measure where the time goes before you guess. Persistent connections are a "measure first" feature — they help when connection setup is your bottleneck and can hurt if it isn't, so the diagnosis tooling came first for a reason. Optimization without measurement is just superstition.
Lock it in with a test
The risk with a cleanup pass like this is regression: someone re-adds an eager load six months from now and nobody notices. So the cleanup deserves a guard. You can assert query counts in Pest to pin down the behaviour:
it('renders the services index without hydrating unused relations', function () {
Service::factory()->count(5)->create();
DB::enableQueryLog();
Livewire::test(ServiceIndex::class)->assertOk();
// The list should be a small, bounded number of queries —
// not one-per-row, and not dragging unused relations along.
expect(count(DB::getQueryLog()))->toBeLessThanOrEqual(5);
DB::disableQueryLog();
});
It's a blunt instrument — a query-count ceiling — but blunt is fine here. The test isn't documenting the exact query plan; it's catching the day someone quietly reintroduces an N+1 and pushes the count through the roof. That's the regression you actually care about.
The takeaway
None of today's changes were clever. That's the point. Performance hardening at this layer is mostly removal — unused eager loads, recomputed values, per-metric count queries — plus the discipline to measure before you optimize and a test or two so the cleanup stays clean. Arm a detector in dev, treat every eager load as a claim you have to justify, memoize the per-request constants, and cache the things that can tolerate being a few seconds stale. Do that sweep once or twice a year and the app stays quietly fast without anyone ever writing a heroic optimization PR.
Top comments (0)