Have you ever needed to show only single related model from the hasMany
relationship on a set of parents?
Being it latest, highest or just random, it’s not very clever to load whole collection using eager loading, just like running query per every parent.
Of course you can do that better, and now let me show you how.
Imagine you are building a blog with Categories
and Posts
. Chances are, the relationship between the two will be simple 1-many:
1 2 3 4 5 6 7 8 9 10 11 12 13 |
// Category model public function posts() { return $this->hasMany('Post'); } // Post model public function category() { return $this->belongsTo('Category'); } |
You want to show categories on the homepage, paginated by 20, and for each category latest post digest. It will look more or less like this:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 |
<h1>Categories</h1> @if ($categories->isEmpty()) <p>Sorry, no categories to show!</p> @else <ul> @foreach ($categories as $category) <li> <strong>{{ $category->name }}</strong> <span>latest post:</span> <article class="latest-post"> <div class="entry-title"> {{ $category->latestPost->title }} </div> <div class="entry-digest"> {{ $category->latestPost->digest }} </div> </div> </li> ... @endforeach </ul> @endif |
Now, the question is, how to fetch that single, latest related Post
model? Let me show you natural path I would go about it:
- Let’s start with single category
123456$category = Category::first();$latestPost = $category->posts()->latest()->first();// select * from `posts` where `posts`.`category_id` = 1 order by `created_at` desc limit 1
nothing fancy, pretty easy and nothing to worry about here. If you need just a single category, that’s the way you could go. But… - … obviously this won’t scale at all:
12345678$categories = Category::take(20)->get();// then in the view:@foreach ($categories as $category){{ $category->posts()->latest()->first()->title }}@endforeach
The same query as above executed in the foreach loop = N+1 issue. -
Eager loading
to the rescue Fortunately Eloquent provides eager loading in order to avoid this performance hit, so all we need is to eager load the relation:
12345678910$categories = Category::with(['posts' => function ($q) {$q->latest(); // sorting related table, so we can use first on the collection}])->take(20)->get();// in the view let's work with the collection instead of querying db@foreach ($categories as $category){{ $category->posts->first()->title }}@endforeach
1 query for all the related posts instead of 20 queries. That was easy, right? Looks like it’s good, only.. it’s not!The thing is, we needed 20 categories + 20 posts, while ,as it happens, we instead loaded bunch of unnecessary posts. Imagine that every category has 100 posts. So we’ve got 2000 posts, instead of 20. Performance hit might not be huge, but definitely we need to do something with it. We need that ram in the end 😉
- So let’s try limiting our eager load query:
1234567$categories = Category::with(['posts' => function ($q) {$q->latest()->limit(1);}])->take(20)->get();// select * from `posts` where `posts`.`category_id` in (1..20) order by `created_at` desc limit 1
This could come to your mind obviously. Unfortunately this is completely wrong. You see that
limit 1
? We can’t limit posts to 1 per category this way. All we did is limiting whole query to 1 row. - Getting to the point then! In order to make it work, we’re gonna need a helper relation, that will let us eager load exactly what we wanted:
123456789101112// Category modelpublic function latestPost(){return $this->hasOne('Post')->latest();}public function posts(){return $this->hasMany('Post');}
Now we can easily do this:
12345678910$categories = Category::with('latestPost')->take(20)->get();// select * from `posts` where `posts`.`category_id` in (1..20) order by `created_at` desc// then:@foreach ($categories as $category){{ $category->latestPost->title }}@endforeachAs you can see, we executed exactly the same query as previously when using eager loading. However this time Eloquent stored only 20 Post objects in memory instead of 2000, because the relation is
hasOne
.
- We benefited in two ways:
- Difference in memory usage can be huge, so we should take advantage of this.
- The code is more readable, easier to mantain and use. We replaced chained methods
$category->posts->first()->whatever
with simple and self-explanatory$category->latestPost;
Stay tuned, next time I’m gonna show you how to do the same with many-to-many relations.
Pingback: Tweaking Eloquent relations – how to get N related models per parent ? | SOFTonSOFA()