Laravel conditioned relationships and its danger zone

A little less known fact about Laravel relationships is that you can make them conditioned. This means, that you can extend the relationships to have a default condition already assigned to them. For example, you want to take latest entries or order them by likes count by default. Here’s a quick guide on how to do that

Setup

I’m going to use two models – Questions and Categories, where Category has many questions assigned to it. Both of them will be generated using Laravel factories so names and text might not make any sense but we are here for the data

Here’s the initial model relationship:

    public function questions()
    {
        return $this->hasMany(Question::class);
    }

And how we are calling it in the controller:

    $categories = Category::with('questions')->get();

Finally, the initial result:

As you can see, we have categories, then their questions listed. The list contains this information: “ID” | “Title” | “Likes”

Lets start conditioning this data!

Sort by “X field” DESC

    public function questions()
    {
        return $this->hasMany(Question::class)->orderBy('likes', 'desc');
    }

Will give us the result of questions sorted down by Likes

Take entries in random order

    public function questions()
    {
        return $this->hasMany(Question::class)->inRandomOrder();
    }

Which will return entries in random order on each of our calls to the relationship:

Filtered entries

    public function questions()
    {
        return $this->hasMany(Question::class)->where('likes', '>=', 5000);
    }

With this – we will filter only the questions that contains more than 5000 likes. (you can use any column for filtering here)

Etc…

At this point you might get an idea that you can append pretty much anything on the relationship (conditions, scopes) – and it will add to our relationship loading query by default. You can check the available methods here: https://laravel.com/docs/master/queries
Most of them should work just fine… But be careful! You might get into some cases where it produces unexpected results!

The danger zone:

I’ve showed you how it works, mentioned that you might use most of the methods that are available in the query builder but here is one of them, that might not work for you at all – “->limit()”

    public function questions()
    {
        return $this->hasMany(Question::class)->limit(2);
    }

As you can see, it seems pretty normal use case – I want to take only 2 questions per category. Well, this sounds good but produces an unexpected result:

As you can see, we ONLY took two entries in total instead of taking two per Question. Why is that?
Well – when you use eager loading – it simply concats all of the required ID and runs a single query that uses mysql “WHERE IN()” condition. That said, our limit is adding to that condition and limits our total results. This is true with LIMIT, GROUP BY and other sensitive conditions that may apply to a bigger scope than you expect.

Bonus:

What if you want to leave the original relationship to get all of the data and filter only in specific conditions? Don’t worry, you can always create a relationship that will extend the original one and add conditions. This way you won’t have to worry about future changes as your relationship definition will be in one place – the main function. Lets look at an example:

Modify our model to look like this:

    public function questions()
    {
        // This is the original relationship
        // You can swap it out anytime to another one and your filters will work without any issues (as long as there required fields)
        return $this->hasMany(Question::class);
    }

    public function popularQuestions()
    {
        // Apply any condition you need, we are taking questions with more than 7000 likes
        return $this->questions()->where('likes', '>', 7000);
    }

And of course, we will change our controller call (views change too!):

    $categories = Category::with('popularQuestions')->get();

The end result works just like it would work with direct assignment with an exception that now we have an option to get unfiltered results too. This is very useful if you are trying to make your code readable and reduce conditioned filters on your controllers.

Leave a Reply

Your email address will not be published. Required fields are marked *

This site uses Akismet to reduce spam. Learn how your comment data is processed.