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.