Eloquent Performance

Some hints and tips to help improve performance of your Laravel application


Install Laravel Debug Bar

To require this package via composer as a dev dependency because we only need this locally and we NEVER want this running in production:


composer require barryvdh/laravel-debugbar --dev

Laravel uses Package Auto-Discovery, so doesn't require you to manually add the ServiceProvider.

The Debug bar will be enabled when APP_DEBUG is true. In your .env file. When enabled you’ll see the following in your browser:


The performance metrics

Page Load


Queries

The Queries tab is a great way to identify N+1 issues as it shows the total number of queries (N+1 issues are not scalable, the more data you have, the more its going to impact performance, you’re executing an extra query per row of data).

It might identify areas of your code where you could utilise eager loading.

In the screenshot below, we’re selecting users, which takes 1.61ms. But what if we needed to order them by their name?


Notice how much longer it takes? It’s jumped from 1.61ms to 28.11ms.


This might be an indication that a database index is required on the ‘name’ column. After adding the index we get a query time of 2.4ms - much faster than 28.11ms!


Memory

The models tab comes in handy here! It shows how many models have been loaded! In the example below, you can see we have 16 company and 16 user models loaded.


But what happens if we eager load users from within the company model?


You may notice a memory increase (in this example it increases by 2MB), and we’re loading a lot more user models! This change has had quite a negative impact on the application.

The Performance
Only Select What You Need

Take this example, the memory usage is at 19.8MB:


This is because we’re pulling all the content from the posts table, which includes the post content (which is quite large), since this page doesn’t need to know the post content for all posts (its listing posts with a link to the individual post page), why pull all the data?

If we update the query to pull the columns we need, this happens:


->select('id', 'title', 'slug', 'author_id')


We’re down to 4.35MB of memory usage and we’ve shaved the page load slightly too. You could also apply to this any eager loaded models too. In this example we only need the author id and name, I.e:

Changing:


->with('author')

To:


->with('author:id,name')

Will improve memory usage too.

N+1 Issues

Assuming you have a logins relationship within your user model, you could add this into your view (in a last login column), it might look something like this:


{{ $user->logins()->latest()->first()->created_at->diffForHumans() }}

In the browser, you’d get the following:


However, in the debug bar, there’s a problem - for every user we display we’re executing an additional query to get their last login (N+1 issue). What if the page displayed 50 users, or 1,000 users?


If we eager load ‘logins’.


->with('logins')

And adjust the view


{{ $user->logins->sortByDesc('created_at')->first()->diffForHumans() }}

This reduces the total amount of queries (from 17 to 3):


However, we’ve introduced another problem - see the number of models above? It’s into the thousands now and the memory usage is going to increase! Eager loading ALL this data is not a better solution.


Before we fallback to caching (Don’t be that guy, or at least use it as a last resort!?), potentially (in this scenario) a sub query could be useful. Using addSelect might be the answer.


->addSelect(['last_login_at' => Login::select('created_at')
    ->whereColumn('user_id', 'users.id')
    ->latest()
    ->take(1);
])
->withCasts(['last_login_at', 'datetime'])

And you’d change the view to:


{{ $user->last_login_at->diffForHumans() }}

This was the situation before:

Now it looks like this:


Dynamic Relationships

Create a relationship and a scope.


public function lastLogin()
{
    return $this->belongsTo(Login::class);
}

public function scopeWithLastLogin($query) 
{
    $query->addSelect(['last_login_id' => Login::select('id')
        ->whereColumn('user_id', 'users.id')
        ->latest()
        ->take(1);
    ])->lastLogin();
}

You’d simply query the model via:


->withLastLogin()

And the view would be updated to the following:


{{ $user->lastLogin->ip_address; }}
{{ $user->lastLogin->created_at->diffForHumans(); }}

You can’t lazy load dynamic relationships as no ‘last_login_id’ will be present on the model, as this depends upon the WithLastLogin scope.

And finally... you might think that using the relationship below, would surely work?


public function lastLogin()
{
    return $this->hasOne(Login::class)->latest();
}

Unfortunately not, it reintroduces the N+1 problem...