Posted on February 19, 2020
In my last article, I explained the importance of pushing database query optimizations to the perimeter of your Laravel applications. As noted then, by doing this, you'll keep your models simpler, since they no longer need to be concerned with performance issues. Today I want to explore a good use-case for this pattern, and that's optimizing circular relationships.
So what exactly are circular relationships? In a way, all relationships are circular. Consider a User
and an Account
. A user belongs to an account, and an account has many users. Another example is a Product
and a Category
. A product belongs to a category, and a category has many products. They are circular because they "point" to each other.
Here's how you might define this in Laravel:
class Product extends Model
{
public function category()
{
return $this->belongsTo(Category::class);
}
}
class Category extends Model
{
public function products()
{
return $this->hasMany(Product::class);
}
}
Now, typically you don't access both sides of a relationship in the same endpoint. For example, if you visit a product category page, you'll load all the products for that category. However, you likely won't load all the categories for those products, since you're already on the category page.
That said, there are situations where you need both sides of a relationship in a single endpoint.
Let's look at an example.
Continuing with our product and category example, consider an application that includes the category slug in the product URL:
/products/{category}/{product}
You might have a url()
method in your Product
model to help you generate the URL. Remember, we don't want this method to worry about performance issues. We simply want to write it in the cleanest way, and then let the controller worry about optimizing things.
class Product extends Model
{
public function category()
{
return $this->belongsTo(Category::class);
}
public function url()
{
return URL::route('product', [
'category' => $this->category->slug,
'product' => $this->slug,
]);
}
}
In the categories.show
template, we'll use the Product::url()
method when creating links to the product pages.
<h1>{{ $category->title }}</h1>
<ul>
@foreach($category->products as $product)
<li>
<a href="{{ $product->url() }}">{{ $product->name }}</a>
</li>
@endforeach
</ul>
Now, if you're paying attention, you'll see an issue here. While we have $category
already loaded in memory, we are also referencing the category
relationship in our new Product::url()
helper method, which doesn't have access to this instance variable. This means we need to eager-load the category for each product, otherwise we'll create an N+1 issue.
class CategoriesController extends Controller
{
public function show(Category $category)
{
// Eager-load the product category to avoid an N+1 issue.
$category->load('products.category');
return View::make('categories.show', ['category' => $category]);
}
}
As you can see, this endpoint is now accessing both sides of this circular relationship. The Category
model is using the products
relationship to display the products, and the Product
model is using the category
relationship to generate the product URL.
There are performance issues that come with loading both sides of a circular relationship:
Looking at our example above, our product category endpoint is actually making two database queries to load the same Category
model. First in our route-model binding, and second when we eager-loaded the product categories.
Further, each time duplicate records are loaded from the database, they require extra processing time, as Eloquent hydrates each model. This may not seem like a big deal in our particular example, but if you're dealing with thousands of records, this can really slow down an endpoint.
Finally, each duplicated record increases the overall memory used, which is often directly connected to page performance.
As it turns out, you can actually avoid all these issues in Laravel by manually assigning relationships. This is done using the Model::setRelation()
method.
Let's rewrite our controller above:
class CategoriesController extends Controller
{
public function show(Category $category)
{
// Manually assign the product categories.
$category->products->each->setRelation('category', $category);
return View::make('categories.show', ['category' => $category]);
}
}
By using this technique, we can avoid running an additional database query to eager-load the product categories. Instead, we take advantage of the $category
instance variable that we already have in memory and simply manually assign it to the products.
And this is a really simple example. You can use this technique in much more complicated situations, such as multiple nested relationships.
So what exactly is Model::setRelation()
? This method can be found in the HasRelationships
trait in the Eloquent source code, and it's actually extremely simple:
/**
* Set the given relationship on the model.
*
* @param string $relation
* @param mixed $value
* @return $this
*/
public function setRelation($relation, $value)
{
$this->relations[$relation] = $value;
return $this;
}
All loaded relationships in Eloquent are stored in the $relations
property. When you try and access one of these relationships, for example $product->category
, Laravel checks this property to see if a key exists for that relationship. If it does, it uses that cached value, otherwise Eloquent lazily loads the relationship from the database.
By manually calling the Model::setRelation()
method, we're basically eager-loading this relationship, and that means Laravel doesn't have to. 👌
What I love about this approach is that my models don't have to be concerned about these performance optimizations. I can (naively) write my models in the cleanest way, and then let the controller worry about optimizing things for that particular endpoint. If you want to learn more about optimizing database queries in the perimeter of your apps, be sure to read this article.
Also, if you're new to database performance, the most important first step is to start tracking your apps performance metrics. That includes tracking your database queries, hydrated models, memory usage, and page speed. The Laravel Debugbar is a fantastic way to do this. Be sure to see this article for help here.
I hope you found this article helpful! If you'd like to learn more about database performance, be sure to also signup for my upcoming Eloquent Performance Patterns course.
Hey there! If you found this article helpful, be sure to check out my Eloquent Performance Patterns video course. You’ll learn how to drastically improve the performance of your Laravel applications by pushing more work to the database, all while still using the Eloquent ORM. Plus, you’ll support me, so I can continue putting out free content like this! 😊