Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[12.x] Added Automatic Relation Loading (Eager Loading) Feature #53655

Open
wants to merge 8 commits into
base: master
Choose a base branch
from

Conversation

litvinchuk
Copy link
Contributor

@litvinchuk litvinchuk commented Nov 24, 2024

Description

In large projects it can become difficult to track and manually specify which relations should be eager-loaded, especially if those relations are deeply nested or dynamically used. Therefore, automatic relation loading can be useful.

# instead of this

$projects->load([
    'client.owner.details',
    'client.customPropertyValues',
    'clientContact.customPropertyValues',
    'status',
    'company.statuses',
    'posts.authors.articles.likes',
    'related.statuses'
]);


# We can use this

$projects->withRelationAutoload();

Challenges include:

  1. Unnecessary overhead when relations change: If the logic for loading relations changes and we forget to remove or update a load() or with() call, unnecessary relations may still be loaded, leading to performance inefficiencies.
  2. Tedious manual loading: Explicitly calling load() or with() for each relation makes the code verbose and harder to read.
  3. Maintenance overhead: As the number of relations grows, the related logic becomes increasingly difficult to maintain and prone to duplication.

New withRelationAutoload() Method

A new method, withRelationAutoload(), has been added to models and Eloquent collections. When called, it automatically loads relations whenever they are accessed, without the need for explicit load() or with() calls.

Example:

$orders = Order::all()->withRelationAutoload();

foreach ($orders as $order) {
    echo $order->client->owner->company->name;
}

// automatic calls:
// $orders->loadMissing('client');
// $orders->loadMissing('client.owner');
// $orders->loadMissing('client.owner.company');

Support for Morph Relations

The feature works seamlessly with polymorphic relations. It only loads the specific morph type that is accessed, ensuring efficient use of resources.

Doesn’t Break Manual Relation Loading

Users can still manually load relations using load() or with() before accessing the relation. If a relation is already loaded manually, it won’t be reloaded.

Global Automatic Loading

For cases where you want automatic loading enabled across all models, you can use the static method `

Model::globalAutoloadRelations();

This feature significantly simplifies working with relations and reduces the overhead of managing eager loading in Laravel projects, enabling developers to focus on application logic instead of data loading mechanics.

If you have suggestions for better method names, feel free to share them!

Copy link

Thanks for submitting a PR!

Note that draft PR's are not reviewed. If you would like a review, please mark your pull request as ready for review in the GitHub user interface.

Pull requests that are abandoned in draft may be closed due to inactivity.

@litvinchuk litvinchuk marked this pull request as ready for review November 25, 2024 13:57
@litvinchuk litvinchuk changed the title Added Automatic Relation Loading (Eager Loading) Feature [12.x] Added Automatic Relation Loading (Eager Loading) Feature Nov 26, 2024
Copy link

@MykolaVoitovych MykolaVoitovych left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It's a very desired and powerful feature. It's going to save a lot of effort. I hope it will be approved soon. Thank you so much!

@stevebauman
Copy link
Contributor

stevebauman commented Nov 26, 2024

I'm not sure I understand this -- isn't this typical Eloquent lazy-loading behaviour? Won't the relations in your example be loaded via lazy-loading? Ex, this should work already:

$orders = Order::all();

foreach ($orders as $order) {
    echo $order->client->owner->company->name;
}

Or are you referring to preventing the possibility from N+1'ing due to the above lazy loading?

Please correct me if I'm misunderstanding 🙏

@litvinchuk
Copy link
Contributor Author

I'm not sure I understand this -- isn't this typical Eloquent lazy-loading behaviour? Won't the relations in your example be loaded via lazy-loading? Ex, this should work already:

$orders = Order::all();

foreach ($orders as $order) {
    echo $order->client->owner->company->name;
}

Or are you referring to preventing the possibility from N+1'ing due to the above lazy loading?

Please correct me if I'm misunderstanding 🙏

You’re absolutely right, and typically, methods like load, loadMissing, or with are used to address the N+1 problem in Eloquent. However, the challenge often arises in scenarios like views, JSON resources, or other parts of the code where a large number of relations are being accessed.

In such cases, it can become difficult to track and manually specify which relations should be eager-loaded, especially if those relations are deeply nested or dynamically used.

The withRelationAutoload() method simplifies this by automatically resolving the required relations the first time they are accessed, without requiring developers to explicitly define them in advance. This ensures that all necessary relations are eager-loaded efficiently in bulk, preventing N+1 problems while reducing the overhead of managing relation loading manually.

In your example:

$orders = Order::all();

foreach ($orders as $order) {
    echo $order->client->owner->company->name;
}

If there are 10 orders, here’s what happens:
In total, this results in 31 queries (1 for orders + 10 for client + 10 for owner + 10 for company).

With withRelationAutoload:
In total, this results in 4 queries (1 for orders + 1 for client + 1 for owner + 1 for company).

It's very simple example, we can just use with the same result

$orders->load('client.owner.company');

But if we change our code in the future, the relations will be loaded unnecessarily until we explicitly remove them from the load.

foreach ($orders as $order) {
    echo $order->client->name;
}

Let me know if this makes sense or if you’d like further clarification! 🙏

@NickSdot
Copy link
Contributor

I am not sure if I fully understand this, so sorry in advance if I don't make sense.

But if this works as you say, wouldn't it make sense to replace the current lazy loading behaviour instead of making your feature opt-in?

@litvinchuk
Copy link
Contributor Author

I am not sure if I fully understand this, so sorry in advance if I don't make sense.

But if this works as you say, wouldn't it make sense to replace the current lazy loading behaviour instead of making your feature opt-in?

Thanks for the question! The feature is opt-in to avoid forcing developers who prefer manual control over relation loading and to preserve the existing lazy-loading behavior. This also helps prevent potential issues in existing projects relying on the default behavior.

However, if desired, you can enable it globally for the entire project using Model::globalAutoloadRelations();

@moisish
Copy link
Contributor

moisish commented Nov 30, 2024

This is a great idea especially for junior devs to avoid N+1 issues

@shaedrich
Copy link
Contributor

shaedrich commented Nov 30, 2024

Isn't that essentially what the $with property on the model does? Well, okay, to be fair: You approached it from the other end.

Eager Loading by Default (from the Laravel docs)

Sometimes you might want to always load some relationships when retrieving a model. To accomplish this, you may define a $with property on the model:

<?php

namespace App\Models;

use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;

class Book extends Model
{
    /**
     * The relationships that should always be loaded.
     *
     * @var array
     */
    protected $with = ['author'];
 
    /**
     * Get the author that wrote the book.
     */
    public function author(): BelongsTo
    {
        return $this->belongsTo(Author::class);
    }

    /**
     * Get the genre of the book.
     */
    public function genre(): BelongsTo
    {
        return $this->belongsTo(Genre::class);
    }
}

@decadence
Copy link
Contributor

decadence commented Dec 1, 2024

Isn't that essentially what the $with property on the model does? Well, okay, to be fair: You approached it from the other end.

No. As far as I understand this PR loads relationship for Collection only when it's accessed from one of Collection's Model. So you don't need with on Builder or with on Model because if relationship is accessed it will be loaded for all Collection.

As a result you don't need to specify relationships names anywhere at all except in the code itself to use them.

With Model with property you can set hard coded array of relationships which will be loaded on every query even if you don't need them.

@litvinchuk
Copy link
Contributor Author

Isn't that essentially what the $with property on the model does? Well, okay, to be fair: You approached it from the other end.

No. As far as I understand this PR loads relationship for Collection only when it's accessed from one of Collection's Model. So you don't need with on Builder or with on Model because if relationship is accessed it will be loaded for all Collection.

As a result you don't need to specify relationships names anywhere at all except in the code itself to use them.

With Model with property you can set hard coded array of relationships which will be loaded on every query even if you don't need them.

I’ll add a few more points:

The $with property doesn’t allow eager-loading morph relationships directly. To load morph relationships, you’d need to use ->loadMorph()

$posts = Post::all()->loadMorph('commentable', [
    Article::class => ['author'],
    Video::class => ['channel'],
]);

However, even ->loadMorph() doesn’t handle morph relationships nested within another morph relationship.

With withRelationAutoload, this happens dynamically:

$posts = Post::all()->withRelationAutoload();

foreach ($posts as $post) {
    echo $post->commentable->author->name; // Automatically loads related morphs and nested relations
}

@shaedrich
Copy link
Contributor


@decadence @litvinchuk Thanks for the explanation! 👍🏻

@RomanZhyla
Copy link

I completely agree with @moisish; it’s a great idea to avoid N+1 issues.

I’ve already tried this functionality, and it works really well.

@litvinchuk, thank you for your explanations. I think this is truly a great solution and worth attention. Great work!

@ejunker
Copy link
Contributor

ejunker commented Dec 5, 2024

I've seen this idea previously done by @liam-wiltshire with https://github.com/liam-wiltshire/laravel-jit-loader which @staudenmeir was a contributor and he is an Eloquent wizard so I feel like this could be a good idea.

@litvinchuk
Copy link
Contributor Author

I've seen this idea previously done by @liam-wiltshire with https://github.com/liam-wiltshire/laravel-jit-loader which @staudenmeir was a contributor and he is an Eloquent wizard so I feel like this could be a good idea.

Thanks for mentioning that! I hadn’t come across this package before, but after taking a look, it seems to work a bit differently from this implementation.

While it also avoids the need to explicitly define relationship names (which is great), it seems to run into N+1 problems after the second level of nested relationships. And it doesn’t support polymorphic relationships.

@ezequidias
Copy link

ezequidias commented Dec 5, 2024

@litvinchuk Thank you so much for this PR! This is undoubtedly one of the most significant improvements, and I can’t wait to start using it. I’ve always been looking for this kind of enhancement in Eloquent, and it’s amazing to see it implemented. Features like this make working with the framework even more enjoyable and efficient. Great job! 🎉

@bulletproof-coding
Copy link

@litvinchuk nice idea. For eloquent collection this makes sense, but for the model alone it doesn't. Can you confirm that this will improve just the eloquent collections?

Also, since laravel 10 https://laravel.com/docs/10.x/releases#main-content
image
typed parameters and returns were marketed and still there is new code added to the framework that follows old codding style. Is this because eloquent will break if new functions are added with typed params and returns, including for closures (i saw @ return int that returned Builder for example)?

@litvinchuk
Copy link
Contributor Author

For eloquent collection this makes sense, but for the model alone it doesn't

Could you clarify why you believe automatic relation loading is not suitable for models? I think it’s particularly useful for deep relations, where it simplifies the process of loading nested dependencies automatically.

typed parameters and returns were marketed and still there is new code added to the framework that follows old codding style. Is this because eloquent will break if new functions are added with typed params and returns, including for closures (i saw @ return int that returned Builder for example)?

I aimed to follow the existing coding style in the relevant classes. If you notice specific issues with type hints or return types, could you please leave a comment under the corresponding line of code?

@bulletproof-coding
Copy link

bulletproof-coding commented Dec 17, 2024

Could you clarify why you believe automatic relation loading is not suitable for models? I think it’s particularly useful for deep relations, where it simplifies the process of loading nested dependencies automatically.

I just asked because if you have the model hydrated in memory, and it is only 1 model, then lazy loading a relation on it will generate just 1 query. Why eager load for just 1 model? (I am not stating, just asking to understand).

If you were referring to 1 model in memory that has loaded in it a relation of to many type so, the relation is a collection not a model, then the eloquent collection auto eager loading applies also to that by default.

typed parameters and returns were marketed and still there is new code added to the framework that follows old codding style. Is this because eloquent will break if new functions are added with typed params and returns, including for closures (i saw @ return int that returned Builder for example)?

I quit doing that because Taylor would revert the changes done by the author before merging so, I concluded that it is not allowed. (later I understood why - it breaks the application because the dockblocks are not accurate - I saw int in dockblock and Builder as response). I just asked you this to have another opinion on it.

@litvinchuk
Copy link
Contributor Author

I just asked because if you have the model hydrated in memory, and it is only 1 model, then lazy loading a relation on it will generate just 1 query. Why eager load for just 1 model? (I am not stating, just asking to understand).

If you were referring to 1 model in memory that has loaded in it a relation of to many type so, the relation is a collection not a model, then the eloquent collection auto eager loading applies also to that by default.

You’re right — when accessing something like $user->company, automatic eager loading doesn’t add much value.
However, the benefit becomes apparent when accessing deeper relations.

For example:

foreach ($user->articles as $article) {
    echo $article->category->name;
}

In this case, we encounter the N+1 problem, where a query is executed for each article’s category.
So $user->withRelationAutoload(); will fix this.

@macropay-solutions
Copy link

For your information, Laravel has a corner case bug/issue on eager loading that will affect (or is affecting) also this feature.

@xHeaven
Copy link

xHeaven commented Dec 19, 2024

For your information, Laravel has a corner case bug/issue on eager loading that will affect (or is affecting) also this feature.

Lowkey funny that nobody actually wants to implement the fix that you shared for some reason. I'd love to take on it, but I feel like I'm way too dumb to go this deep into the Eloquent core.

@macropay-solutions
Copy link

@xHeaven The good news is that also this feature can be implemented via a lib/package if it is not merged into the core, just like that bug fix.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.