Be less eager: using without() alongside $with in Laravel

Published April 24th, 2024

Laravel’s Eloquent allows us to define relationships as methods on our models, which we can access as properties within our code. I’m not here to teach you about Eloquent relationships, eager loading and why and when you want to use it – Laravel’s documentation is awesome so give it a read.

For a project, I’ve got a model that has a number of relationships that store supporting and necessary data, and any time I use the model, I need them. Always.

Well, nearly always. I needed to figure out how to disable eager loading for a nested relationship that had its relationships loaded with $with. And so here we have the topic for this blog post.

For simplicity, let’s say our base Program has multiple one-to-many relationships – let’s say Types and Costs. Our model and relationships may look a little like this:

1use Illuminate\Database\Eloquent\Model;
2use Illuminate\Database\Eloquent\Relations\HasMany;
3 
4class Program extends Model
5{
6 public function costs(): HasMany
7 {
8 return $this->hasMany(ProgramCost::class);
9 }
10 
11 public function types(): HasMany
12 {
13 return $this->hasMany(ProgramType::class);
14 }
15}
Copied!

We can use the with method when querying data to eagerly load these relationship to avoid the N+1 issues:

1use App\Models\Enrolment;
2use App\Models\Program;
3 
4// for the model itself
5$programs = Program::with([
6 'costs',
7 'types'
8])
9// ...
10->get();
11 
12// or from another model
13$enrolments = Enrolment::with([
14 'program',
15 'program.costs',
16 'program.types'
17])
18->get();
Copied!

For a single Program, depending on the query, the impact may not be noticeable. But when creating a table or list of a number of Programs, the N+1 problem can take a major toll on performance. Using with is critical to keeping the app feeling snappy.

Given these relationships are always needed, instead of calling with every time, I can also eager-load these by default using the $with property on our model:

1use Illuminate\Database\Eloquent\Model;
2 
3class Program extends Model
4{
5 // load the 'costs' and 'types' relationships
6 // every time when loading a Program
7 protected $with = [
8 'costs',
9 'types'
10 ];
11 
12 // ...
13}
Copied!

This means that a simple load of the Program model will automatically eager load the Types and Costs relationships.

Now this can be good and bad – it’s not suitable for every project and use case, but for this particular model, it makes sense. I need those relationships all the time, so eager loading in one place (the model itself) makes querying in other parts of the app simpler and less repetitive.

But, a recent feature request added a view that legitimately did not need these relationships, and for the first time ever in this app. The catch is that another model, the Enrolment model, is loading the Program, and doesn’t need any of the related data.

1use App\Models\Enrolment;
2 
3// get me all Enrolments with their Program model eagerly loaded
4$enrolments = Enrolment::with(['program'])->get();
Copied!

The issue here is that all of the Program’s eager load relationships are loaded too - our costs and types – and for some conditions, can lead to a lot of additional data being loaded for no reason. The catch is that we have hard-coded our $with array, meaning they’re always loaded.

Never fear, Laravel has our back and also comes with a without method that allows us to remove specific relationships from our $with property.

1use App\Models\Program;
2 
3// get me Programs without the Costs and Types relationships eagerly loaded
4$programs = Program::without(['costs', 'types'])->get();
Copied!

But how can we do this when writing our Enrolment query that is eager loading the Program which in turn is loading its $with relationships? One way to accomplish this is to know that with can be passed a closure for a specific relationship to allow us to add constraints to our eager loading.

1use App\Models\Enrolment;
2use Illuminate\Contracts\Database\Eloquent\Builder;
3 
4$enrolments = Enrolment::with([
5 'program' => function(Builder $query) {
6 // ...
7 }
8])->get();
Copied!

Our closure gets a Builder instance which means we then have access to all Laravel’s query builder awesomesauce, including the with() and without() methods. This means we can call without() to then exclude our Program’s $with relationships from the query, just for this one specific query:

1use App\Models\Enrolment;
2use Illuminate\Contracts\Database\Eloquent\Builder;
3 
4$enrolments = Enrolment::with([
5 'program' => function(Builder $query) {
6 // the query builder is now at the Program model level
7 // here we can exclude the 'costs' and 'types' relationships
8 $query->without([
9 'costs',
10 'types'
11 ]);
12 }
13])->get();
Copied!

When we are calling with to eager load, we can include child relationships, such as programs.costs however we can’t call without on our Enrolment query to not load the child relationships: it needs to be done at the Program level, not the Enrolment level.

1// does NOT work - Program $with eager loads are still loaded
2$enrolments = Enrolment::query()
3 ->with(['program'])
4 ->without([
5 'program.costs',
6 'program.types'
7 ])
8 ->get();
Copied!

One way to think about this is like “by exception”.

Rather than saying “I want to eager load always, except this one use case”, we can say “I want to always eager load, except this one use case”. Turn it off by exception.

This means that if we added another relationship that always needed to be loaded, we can make two changes: one to $with, and one to our by-exception without call.

It is great to be eager: but sometimes there is a thing as too much eager. Hopefully you’ve learnt that it is OK to be less eager too.

You may be interested in...