Jonathan
Reinink

Optimizing circular relationships in Laravel

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.

What are 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.

Using both sides of a circular relationship

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.

The problem with circular relationships

There are performance issues that come with loading both sides of a circular relationship:

  1. Extra, unnecessary, database queries.
  2. Additional processing time.
  3. Higher overall memory usage.

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.

Manually assigning relationships

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.

Before
3
database queries
2
category models
x
product models
After
2
database queries
1
category model
x
product models

And this is a really simple example. You can use this technique in much more complicated situations, such as multiple nested relationships.

What is setRelation?

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. 👌

Closing thoughts

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.

If you want to be notified in the future about new articles, as well as other interesting things I'm working on, join my mailing list!
I send emails quite infrequently, and will never share your email address with anyone else.