Profile    Mohammed Shiroz Status   Loading  
Logo
Share This
Back to blog
Filter by:
Tags
//Article title

Five Eloquent Mistakes That Quietly Slow Down Your Laravel App

About Post

Eloquent's greatest strength is that database code reads like English. $tenant->contracts->count(). Lovely. Obvious. Done.

That's also its trap. The slowest patterns look exactly as innocent as the fast ones. They work perfectly in development with 30 rows, pass code review, and then get a little slower every month in production until someone opens a ticket that just says "the dashboard is slow".

Here are five mistakes I've seen in many codebases (and, early on, written myself), each with why it hurts and the fix.

Mistake 1: all(), then filter in PHP

$active = Tenant::all()->where('status', 'active');

Why it hurts: all() runs SELECT * FROM tenants and builds a model object for every single row. The where() after it isn't SQL at all. It's a Collection method filtering in PHP memory. The database did the most work possible, then PHP threw most of it away.

The confusing part is that Collections and the query builder share method names like where, sortBy and first, so the code looks right.

The fix: filter before you fetch.

$active = Tenant::where('status', 'active')->get();

A simple test: if get() or all() appears before your filtering, the filtering is happening in PHP.

Mistake 2: counting by loading everything

$open = Ticket::where('status', 'open')->get()->count();

if ($tenant->contracts->count() > 0) { /* ... */ }

Why it hurts: the first line loads every open ticket into memory just to count them. The second is sneakier: $tenant->contracts (no brackets) loads the whole relation, all columns, all rows, to answer a yes/no question.

The fix: ask the database the question you actually have.

$open = Ticket::where('status', 'open')->count();          // SELECT COUNT(*)

if ($tenant->contracts()->exists()) { /* ... */ }           // SELECT EXISTS(...)

$tenants = Tenant::withCount('contracts')->paginate(25);  // count per row, one query

Note the brackets: contracts() returns a query you can keep building on, while contracts returns the loaded results. One pair of brackets is the difference between a count and a full load. And exists() beats count() > 0 because the database can stop at the first match.

Mistake 3: selecting every column, every time

$contracts = Contract::with('tenant')->latest()->paginate(50);

Why it hurts: this fetches every column of every contract and every tenant. If those tables have a long terms text column, a JSON metadata blob, or a stored document payload, you're moving all of it across the network and hydrating it into models, only to display a number, a name and a date.

The fix: select what the screen needs.

$contracts = Contract::query()
    ->select(['id', 'tenant_id', 'number', 'end_date'])
    ->with('tenant:id,name')
    ->latest()
    ->paginate(50);

The gotcha: always include the keys the relationship needs. Leave out tenant_id in the parent, or id in the tenant:id,name list, and Eloquent can't match them up. Every tenant silently comes back as null, with no error. Also note that latest() orders by created_at, which doesn't need to be selected to sort by it.

For list screens and API index endpoints, this one change is often bigger than people expect. For a single-record detail page, don't bother; the gain is too small to justify the extra code.

Mistake 4: loading relationships inside a loop

$contracts = Contract::where('status', 'active')->get();

foreach ($contracts as $contract) {
    echo $contract->tenant->name;     // one query per contract
    echo $contract->unit->code;       // and another one
}

Why it hurts: this is the famous N+1 problem. One query for the contracts, then one more per contract for each relationship you touch. Two relationships and a few hundred rows means hundreds of queries for one page, each one fast on its own and painfully slow together. It hides especially well in Blade views and API resources, far from the controller that ran the query.

The fix: eager load, and make Laravel shout when you forget.

$contracts = Contract::with(['tenant', 'unit'])->where('status', 'active')->get();

// AppServiceProvider::boot()
Model::preventLazyLoading(! app()->isProduction());

With preventLazyLoading, any accidental lazy load throws an exception in development and tests, so the N+1 is caught on your machine instead of in production. It's off in production on purpose: there, a slow page is better than a broken one.

Mistake 5: processing big tables in one go

// Nightly job: mark expired contracts
Contract::where('end_date', '<', now())->where('status', 'active')
    ->get()
    ->each(fn ($c) => $c->markExpired());

Why it hurts: fine on day one, an out-of-memory error on day four hundred. get() loads every matching row at once, and as the table grows, so does the memory needed to run your job.

The fix: work in batches.

Contract::where('end_date', '<', now())->where('status', 'active')
    ->chunkById(500, function ($contracts) {
        $contracts->each(fn ($c) => $c->markExpired());
    });

The gotcha: use chunkById(), not plain chunk(), when you update the rows you're filtering on. Plain chunk() uses LIMIT and OFFSET. As you mark rows expired, they drop out of the result set, the offset skips ahead, and roughly half your rows never get processed. chunkById() pages by "id greater than the last one seen", which doesn't care that the set is shrinking. If you prefer a simple loop, lazyById() gives you the same safety one model at a time.

And if the update is the same for every row, skip models entirely: Contract::where(...)->update(['status' => 'expired']) is a single query. Just remember that bulk updates don't fire model events or observers.

The habit behind all five: before you call get(), ask "what does the database need to return for this to work?" Then make the query return exactly that, and nothing more.

Cheat sheet

Instead ofUse
Model::all()->where(...)Model::where(...)->get()
->get()->count()->count() or withCount()
->count() > 0->exists()
SELECT * on list screensselect([...]) + with('rel:id,name')
Relations in a loopwith() + preventLazyLoading()
->get() on big jobschunkById(), lazyById() or a bulk update()

To find these in your own app, install Laravel Debugbar or Telescope locally and look at the query count on your busiest pages. The numbers usually tell the story on their own.

Which of these five have you found in a real codebase? And is there a sixth you'd add to the list?

Comments (0)
Leave your review

Thanks for your valuable comments. Your comments has been updated and appreciate your getting in touch...

01. About Shiroz

Mohammed Shiroz

Hi, I'm Mohammed Shiroz, a software engineer and AI enthusiast from Sri Lanka who turns ideas into intelligent, real-world solutions. With over 9 years of hands-on experience, I currently lead real estate ERP development at Kate Group, a...

03.My Projects

04. Categories

Ready To order Your Project ?

Get in Touch
Close