Sometimes Eloquent can make a query slower

Eloquent improves developer efficiency, but in some cases it can make a query less efficient. Let's look at one example and how to fix it.

Joel Clermont
Joel Clermont
2023-08-14

Eloquent is great, and it saves so much developer time! But occasionally a really nice-looking Eloquent query can be measurably slower than using some more basic SQL syntax.

In a recent project, I was fetching the two most recently updated records, but I only wanted results where a relationship existed and was not soft-deleted. This is really easy to express in Eloquent.

$recentlyCompleted = Todo::completed()
    ->whereHas('user')
    ->latest('completed_at')
    ->take(2)
    ->get()
select * from `todos`
where
  `completed_at` is not null
  and exists (
    select * from `users`
      where `todos`.`user_id` = `users`.`id`
        and `users`.`deleted_at` is null
      order by `name` asc
  )
  and `todos`.`deleted_at` is null
order by `completed_at` desc
limit 2

In this code snippet, you see the Eloquent query on top, and the SQL statement it runs below it. One thing of note is how the really terse whereHas('user') gets translated into that exists subquery in SQL.

Now lets take a look at a less-eloquent query, but one that performs better:

$recentlyCompleted = Todo::join('users', 'todos.user_id', '=', 'users.id')
    ->select(['todos.*'])
    ->with(['user'])
    ->whereNull('users.deleted_at')
    ->completed()
    ->latest('completed_at')
    ->take(2)
    ->get();
select `todos`.*  from `todos`
  inner join `users` on `todos`.`user_id` = `users`.`id`
where `users`.`deleted_at` is null
  and `completed_at` is not null
  and `todos`.`deleted_at` is null
order by `completed_at` desc
limit 2

By using a more explicit SQL syntax, I bypass the subquery, but more importantly, I simplified the relationship logic. Notice there's no more ordering of the user records. That global scope on the User model got applied automatically with Eloquent, but wasn't necessary in this particular case.

With this one change, the query got 30% faster. And since it was on the dashboard you see after logging in, it was going to get called a lot. There are other ways this could have been improved, but those all sound like separate tips for a future day.

It's important to note I'm not saying "never use whereHas." I continue to default to writing queries with Eloquent syntax and only deviate when something is slower than I'd like, and I'm exploring possible fixes. And when you find a place where deviating from Eloquent makes sense, add a simple comment above the query explaining why. It will save your teammate (or future you) some head-scratching 6 months from now.

Oh, and if you're wondering how to detect what is slow or diagnose why it's slow, stay tuned for some future tips on that topic.

Here to help,

Joel

P.S. Let me know if you need help with your performance issue. It's fun for me, like solving a puzzle.

Toss a coin in the jar if you found this helpful.
Want a tip like this in your inbox every weekday? Sign up below 👇🏼

Level up your Laravel skills!

Each 2-minute email has real-world advice you can use.