Jonathan Reinink wrote a great blog post about Optimizing circular relationships in Laravel
By manually setting the (belongsTo
) relationship to a parent model on related (hasMany
) child models, you can save unnecessary queries for the parent model -- when the child needs an instance of the parent model.
This probably sounds confusing, so just read the blog post. It's very good.
Jonathan's approach suggests using something like this:
$category->products->each->setRelation('category', $category);
This works, but it's not very clean and there are cases when it doesn't work. For example, on model creation.
If you're accessing the parent model in creating
and saving
events on the children, the ->each->setRelation()
approach won't help you at all. (And if you're building a complex app with Laravel Nova, there's a high chance you're using lots of such events.)
I have an e-commerce application where an Order
has child models: OrderProduct
, OrderStatus
and OrderFee
(think shipping costs, payment fees, etc).
When some of those models are being created (creating
Eloquent event), they are accessing the parent model.
For example, OrderProduct
s convert their prices to $this->order->currency
. OrderFee
s check for other order fees, and they prevent creating themselves if a fee with the same code already exists (so that you can't have, say, the shipping cost counted twice). Etc.
This results in order creation being expensive, resulting in a large amount of n+1 queries.
I haven't run a huge amount of tests, so I won't present the time differences here. I will only talk about database query count.
I have created an order with 6 products.
And now I just replace all of these calls:
return $this->hasMany(...);
with these calls
return $this->hasManyWithInverse(..., 'order');
inside the Order
model.
See the query count reduction.
The duration was also decreased from 114ms to 45ms on my machine, though note that I did not run this test a million times to calculate an average duration, so that benchmark might not be very accurate.
This is pretty impressive for a free improvement that only requires changing a few simple calls to a similar method.
But note that this is not a silver bullet for solving all n+1 queries. As you can see, even with this implemented, my app still has many duplicated queries. (Although not all are unintentional n+1s as there are a few $this->refresh()
calls to keep the order up-to-date after state transitions).
Laravel 6.x, 7.x, and 8.x is supported.
composer require stancl/laravel-hasmanywithinverse
namespace App;
use Stancl\HasManyWithInverse\HasManyWithInverse;
class Order extends Model
{
use HasManyWithInverse;
public function products()
{
// 'order' is the name of the relationship in the other model, see below
return $this->hasManyWithInverse(OrderProduct::class, 'order');
}
}
class OrderProduct extends Model
{
public function order()
{
return $this->belongsTo(Order::class);
}
}
You may also want to use the trait in a base Eloquent model and then use $this->hasManyWithInverse()
without thinking about traits in the specific models.
The (simple) internals of the package are just methods copied from Eloquent source code, with a few lines added to them. The hasManyWithInverse()
method signature is the same as hasMany()
(you can set $foreignKey
and $localKey
), except the second argument ($inverse
) was added to let you define the name of the relationship on the child model, and the last argument ($config
) was added to let you configure the relation setting's behavior.
This package sets the parent relation on children both when creating children ($child = $parent->children()->create()
) and when resolving parent's children ($children = $parent->children
). You can customize this behavior for every relationship.
To disable setting the relationship during child creation, do this:
class Parent extends Model
{
public function children()
{
return $this->hasManyWithInverse(Child::class, 'parent', null, null, ['setRelationOnCreation' => false]);
}
}
To disable setting the relationship during child resolution, do this:
class Parent extends Model
{
public function children()
{
return $this->hasManyWithInverse(Child::class, 'parent', null, null, ['setRelationOnResolution' => false]);
}
}
You may also pass a callable as the config value. This is useful if you want to disable this behavior on some requests. See example below.
It's a good idea to disable setting the relationship on resolution for Nova requests. They tend to make a lot of queries and this can slow the page down (or result in 502 errors).
Here's an example implementation using a base model and adding config to filter out Nova requests.
abstract class Model extends EloquentModel
{
use HasManyWithInverse {
hasManyWithInverse as originalHasManyWithInverse;
}
public function hasManyWithInverse($related, $inverse, $foreignKey = null, $localKey = null, $config = [])
{
$config = array_merge(['setRelationOnResolution' => function () {
if (request()->route() && in_array('nova', request()->route()->middleware())) {
return false;
}
}], $config);
return $this->originalHasManyWithInverse($related, $inverse, $foreignKey, $localKey, $config);
}
}