The N+1 Audit: From 100+ Queries to 2
API Resources make it easy to hide database queries inside serialization. A full audit found four patterns silently running 100+ queries per request.
Laravel's API Resources are elegant -- define toArray() and the framework handles serialization. They also make it dangerously easy to hide database queries inside transformation logic.
The Problem
Loading 50 contacts triggered 100+ database queries. The culprit was inside ContactResource::toArray():
'next_appointment' => $this->getNextAppointment(),
That method ran 1-2 queries per contact to find their next appointment. Multiply by 50 contacts and you're at 100+ queries for a single API call.
The same pattern appeared elsewhere:
- AppointmentResource queried the currently-reading book per appointment for reading-type events
- Blade templates called
Book::getCurrentlyReading()once per day column -- 7 identical queries on the week view
The Fix: Preload, Don't Query Per-Item
For contacts, I bulk-fetched all next appointments in the controller and stored them in a static property:
// Controller: 2 queries for ALL contacts
ContactResource::preloadNextAppointments($contacts, $user->timezone);
// Resource: just a lookup, zero queries
'next_appointment' => self::$nextAppointments[$this->id] ?? null,
For the book lookup, same idea -- resolve it once in the controller, set it as a static on AppointmentResource. For Blade, pass the book from the controller instead of each partial querying independently.
The Safety Net
The most important fix was one line:
Model::preventLazyLoading(! $this->app->environment('production'));
Now any accidental lazy load throws an exception in development. N+1s are impossible to miss going forward.
Results
| Endpoint | Before | After |
|---|---|---|
| GET /contacts (50 contacts) | ~100-150 queries | 2 queries |
| GET /planner (reading appointments) | N per reading apt | 1 query |
| Week view (7-day Blade) | 7 duplicate queries | 1 query |
Takeaway
Never query inside toArray(). API Resources should format data that's already loaded. If you need computed data, preload it in the controller. And turn on preventLazyLoading -- it's the single best prevention measure and it costs nothing.
Built while working on PlanPal.
Back to danielships ->