Topic 12 of 75·M15
Eloquent Query Optimization
Must know`select` · `where` · `whereHas` · `with` · `withCount` · `exists` · `chunk` · `chunkById` · `cursor` · `lazy` · pagination · indexes · query logging · `EXPLAIN`
The one-sentence answer
Query optimization is making sure each Eloquent call fetches only what you need, in the fewest queries, using indexes — measured with the query log and EXPLAIN, not guesses.
The mental model
Every Eloquent query has three costs you can control:
- How many queries — eager loading collapses N+1 into 2 (Lesson 11).
- How much data —
select()andexists()avoid hydrating columns and rows you don't need. - How the DB finds it — indexes turn full scans into lookups.
The senior answer to "your endpoint is slow" always starts the same way: measure first (query log, EXPLAIN), then fix the query, then the index — never guess.
The methods that matter
Fetch only what you need
// don't hydrate everything
User::where('active', true)->get(); // filter with where
User::where('active', true)->exists(); // just a boolean — no hydration
User::where('active', true)->count(); // just a number — no hydration
User::select('id', 'name')->get(); // only these columns
User::pluck('name'); // just one column, as a list
Query through relationships
User::whereHas('posts', fn ($q) => $q->where('published', true))->get();
// users who have a published post — EXISTS-style filter, no posts hydrated
User::withCount('posts')->get(); // users + posts_count column
User::withCount(['posts' => fn ($q) => $q->where('published', true)])->get();
whereHas filters by a relationship; withCount adds a count without loading the relation.
Large result sets — don't load 1M rows into RAM
// chunk: process N rows at a time (safe against memory blowups)
User::chunk(500, function ($users) { foreach ($users as $u) { /* handle */ } });
// chunkById: stable for datasets that change during processing (by PK)
User::orderBy('id')->chunkById(500, fn ($users) => /* ... */);
// cursor: lazy stream of models — one at a time
foreach (User::cursor() as $user) { /* never holds all in memory */ }
// lazy: the same idea but returns a lazy collection
User::lazy()->each(fn ($user) => /* ... */);
The "1 million records" answer (checklist Scenario 2) is: don't fetch them all — chunk/cursor/lazy, or paginate.
Pagination
User::paginate(50); // classic — page numbers + total (COUNT query)
User::simplePaginate(50); // prev/next only — no COUNT, cheaper
User::cursorPaginate(50); // keyset pagination — stable for huge sets, no offset
cursorPaginate avoids the offset problem at scale: it pages by a cursor key instead of OFFSET which degrades on deep pages.
Query logging & EXPLAIN
DB::enableQueryLog();
User::where('status', 'paid')->get();
dump(DB::getQueryLog()); // see the SQL + bindings
// EXPLAIN — is the index being used?
DB::select('EXPLAIN ' . User::where('status', 'paid')->toSql());The plain-JS model (what the exercise does)
// fetch only what you need — the shape, not the SQL
users.filter(u => u.active); // where
users.some(u => u.active); // exists
users.length; // count — no hydration
users.map(u => u.name); // pluckInterview questions
Q1. How would you optimize an endpoint returning 1 million records?
I wouldn't return a million records. I'd ask what the client actually needs: paginate (
cursorPaginatefor huge sets), or stream withchunk/cursorfor processing. If the endpoint truly returns a big list, add filtering, sorting, and pagination at the API boundary (Lesson 23). The query itself getsselectfor only the needed columns and an index on the filter column.
Q2. whereHas vs with?
whereHasfilters the parent by whether a relationship matches — "users with a published post" — using anEXISTSquery and hydrating only the users.withloads the related models for display. One is a filter, the other a fetch.
Q3. chunk vs cursor?
chunkfetches N models at a time and passes each batch to a callback — bounded memory, good for heavy per-row work.cursorstreams one model at a time with a generator — the lowest memory footprint.chunkByIdis the stable variant when the dataset changes mid-run (it pages by primary key, so inserts/deletes don't skip rows).
Q4. Why is cursorPaginate better for very large datasets?
Classic
paginateusesOFFSET, which gets slower and less stable on deep pages (the DB skips N rows each time) and double-reads under writes.cursorPaginatepages by a key — "give me the next 50 after this cursor" — so deep pages cost the same as the first, and concurrent changes don't shift the page.
Q5. How do you use EXPLAIN to find a slow query?
Run
EXPLAINon the query SQL and read thetypeandrowscolumns:ALLmeans a full table scan (missing index),ref/rangemean an index is being used, androwsshows how many rows the DB examined. The fix is usually an index matching theWHERE/ORDER BYcolumns.
Senior follow-up: Walk me through the "1 million records" scenario.
First, measure: enable the query log or Telescope, see what's slow. Second, fix the shape:
chunkByIdorcursorif it's a batch job — never hydrate a million models. Third, index the filter columns andselectonly what's needed. Fourth, if it's an API, paginate and let the client filter. The answer is: don't fetch them all, and prove it with the query log.
Common mistakes
❌ ->get()->count() — hydrates everything; use ->count().
❌ Loading a 1M-row table into memory — chunk/cursor/lazy exist for exactly this.
❌ Deep OFFSET pagination on huge tables — keyset (cursorPaginate) is the fix.
❌ Adding an index without EXPLAIN — measure first, then index.
Quick revision notes
- Optimize in order: measure → fix the query → add the index
exists()/count()/select()/pluck()— don't hydrate what you don't renderwhereHasfilters by a relationship ·withCountcounts it- Big sets:
chunk/chunkById/cursor/lazy— bounded memory - Pagination:
paginate(COUNT) ·simplePaginate(prev/next) ·cursorPaginate(keyset) EXPLAINshowsALL(scan) vsref/range(index)
Check your understanding
Answer each one out loud. Then rate yourself honestly.
0/5What's the order of operations for optimizing a slow query?
whereHas vs with vs withCount — when is each right?
Why does chunkById beat chunk when the data changes mid-run?
What makes cursorPaginate scale where OFFSET doesn't?
What do ALL and ref mean in EXPLAIN, and what do you do about it?