Oh Dear is the all-in-one monitoring tool for your entire website. We monitor uptime, SSL certificates, broken links, scheduled tasks and more. You'll get a notifications for us when something's wrong. All that paired with a developer friendly API and kick-ass documentation. O, and you'll also be able to create a public status page under a minute. Start monitoring using our free trial now.

Increase performance by using Eloquent's `setRelation` method

Original – by Freek Van der Herten – 5 minute read

While working on the upcoming Oh Dear redesign, I noticed that list that displays all sites of a team was very slow.

To display that list, a lot of queries were used. With a couple of minor adjustments, I could reduce the number of queries needed to just a single one, solving the performance problem.

In this small blog post, I'd like to share one of the techniques I used.

Discovering an N+1 problem

Let's look at a simplified version of how we fetch the data to build the site list for the current team.

$sites = Team::current() // returns an instance of the team model
	->sites()
	->get()
	->filter(fn (Site $site) => currentUser()->can('administer', $site));

In the code above, you can see that we filter out the sites that the current user isn't allowed to see. We use a policy check for that, and not a simple where clause, as there is some business logic needed to decide if someone is allowed to see a site.

Now let's look at the administer method of the SitePolicy.

class SitePolicy
{
    use HandlesAuthorization;

    public function administer(User $user, Site $site): bool
    {
        if ($user->ownsTeam($site->team)) {
            return true;
        }

        // other rules...
    }
}

So the first rule is that if the user owns the team to which the site belongs, he/she can view the site. You can see that we call $site->team to get the team.

Getting the team there is an n+1 problem: this query is executed for each site we loop over. When displaying a site list for teams with 200 sites, 200 extra queries are performed, and this is what makes the site list slow.

From eager loading the relationship...

To solve the n+1 problem, we can eager load the relationship by using with in our query.

Here's the modified code:

$sites = Team::current() // returns an instance of the team model
	->sites()
	->with('team')
	->get()
	->filter(fn (Site $site) => currentUser()->can('administer', $site));

This successfully solves the N+1 problem. We now fetch all of the sites and teams in one query, and we don't need to execute multiple queries to fetch sites.

... to setting the relation manually

Most of you, and past me, would call it a day. We've solved the n+1 problem; now we can go home (or work on other stuff if it's not the end of the workday).

The solution above has a couple of problems. But before listing them, I want to point you to the fact that we've asked Eloquent to get all the sites for a team. So it is no surprise that all sites have the same team.

But what is logical for us as humans isn't logical for MySQL/Laravel. Here are a couple of things that are not optimal now

  • even though we know that each site has the same team, in MySQL's query result to Laravel, each row will have information about the team. The query result is larger than it needs to be
  • Laravel will convert the query result to Site objects, and Team objects. We as humans know that all teams are the same, but Laravel will still create a new Team object in memory for each team. This conversion, or with a better word "hydration", will take a little bit of time
  • All of the Team objects that Laravel creates are separate instances, meaning that they all will take some memory.

The problems above are not that big if you only have a few results. But if you have hundreds or thousands of results, you might notice a small performance hit.

Isn't it a shame that all this time and memory is needed to process the same team over and over again?

Luckily, we can tackle our original N+1 problem in another way that avoids all the little problems listed above. Let's take a look at our original query.

$sites = Team::current() // returns an instance of the team model
	->sites()
	->get()
	->filter(fn (Site $site) => currentUser()->can('administer', $site));

Notice that in the code above, we already of the instance of the team model available. Let's rewrite the code, so we store the team in a separate variable.

$team = Team::current();

$team
	->sites()
	->get()
	->filter(fn (Site $site) => currentUser()->can('administer', $site));

Now, instead of eager loading the team for each site, we will manually set the team relation of a model. This can be done using the setRelation method.

$team = Team::current();

$team
	->sites()
	->get()
	->map(fn (Site $site) => $site->setRelation('team', $team))
	->filter(fn (Site $site) => currentUser()->can('administer', $site));

By using setRelation, Eloquent will not reach out to the database anymore to get the team relation, it will simply return the Team instance we passed.

Because we didn't rely on eager loading:

  • MySQL doesn't have to return any team results when fetching sites
  • Eloquent doesn't need to hydrate all those Team objects anymore
  • memory usage is lower, as we only have a single Team object in memory, and not one per site.
  • we avoided the N+1 problem

In conclusion

I hope that you liked this little Eloquent performance tip. I originally heard of this method by reading Jonathan Reinink's blog post on circular relationships, and I highly recommend your reading if you want to see another example of setRelation.

Like mentioned in the intro, I'm using this technique in Oh Dear, the all-in-one monitoring tool for your entire website. Oh Dear monitors uptime, SSL certificates, broken links, scheduled tasks, application health, DNS, domain expiry and more. We send notifications when something's wrong. All that paired with a developer-friendly API and kick-ass documentation. Register to start your 10 day free trial.

Stay up to date with all things Laravel, PHP, and JavaScript.

Follow me on Twitter. I regularly tweet out programming tips, and what I myself have learned in ongoing projects.

Every month I send out a newsletter containing lots of interesting stuff for the modern PHP developer.

Expect quick tips & tricks, interesting tutorials, opinions and packages. Because I work with Laravel every day there is an emphasis on that framework.

Rest assured that I will only use your email address to send you the newsletter and will not use it for any other purposes.

Comments

avatar

Hi Freek, thanks for sharing! My understanding of eager loading in Laravel was that it doesn't "JOIN" the extra team columns onto the original sites query, but rather that it would fetch the sites first, then issue a single SELECT * FROM teams WHERE id IN {$sites->pluck('team_id')} query — at least for direct (i.e. non-pivot) relationships. So only one team would need to get hydrated from that query, because the team_id is the same for all the sites. Please let me know if I'm mistaken.

Comments powered by Laravel Comments
Want to join the conversation? Log in or create an account to post a comment.