13 March 2025

Lessons From Eloquent Performance Patterns (Cheatsheet)

In this article, I will summarise what I learned from Jonathan Reinink course on database optimization tailored for Laravel applications using Eloquent ORM.

Measure Your Database Performance.

The first step in optimizing the database performance of a Laravel application is to effectively measure its performance. Several tools can help with this, with the most common being Laravel Debug Bar. Additionally, I highly recommend using Laravel Telescope for deeper insights.

Here are three key metrics to monitor when using Laravel Debug Bar or Telescope to optimize database performance:

Memory Usage

If a Laravel 12 application is consuming significantly more than 1MB of RAM per request, inefficient database queries are often the culprit.

Database Queries

Query execution time and duplicates (or N+1) queries are the most important metrics that help you understand the performance of your Laravel application.

Loaded Models

Sometimes, your database queries may be fast and free of duplicates but still return a large number of Eloquent models. This can lead to high memory usage in your application. A common solution is to offload heavy logic operations to the database layer.

Minimize Memory Usage

This is the second item Jonathan pointed out in his course. We have to make sure we minimise the memory usage of our database queries by selecting only the columns we from the database.

✅ DO 

Post::get(columns: ['id', 'title', 'published_at']); 

Post::find(id: 1, columns: ['id', 'title', 'body', 'published_at']);
❌ DONT

Post::get();

Post::find(id: 1);

Using Subqueries

Subqueries are queries that run inside another query as a single query. We can use subqueries to pull additional information from other tables or compute derived values.

When optimizing a database query for performance, the focus is on keeping both the number of database queries and memory usage to a minimum. A good starting point for minimizing query count is solving N+1 queries. The next step is reducing the memory usage of database queries, and that's where subqueries come to the rescue.

For example, let's say we want to load the last time a user logged into our application. The default solution is to define a HasMany relationship between the User model and the Login model, which records the user's login activities.

class User extends Model
{
    public function loginActivity(): HasMany 
    {
        return $this->hasMany(Login::class)
    }
}

Now, we can eagerly load the login activities and sort them to retrieve the latest one.

❌ DONT

$user = User::with('loginActivity')->first();

$lastLoginAt = $user->loginActivity->sortByDesc('created_at')->first()->created_at;

While this seems to solve our problem, there will be a memory issue when dealing with a collection of users or loading a user with thousands of login activities. This is because we are eagerly loading all the login activities of a user and storing them in memory. The more users we load, the more activities we store in memory.

We can solve this memory issue using subqueries. Subqueries can only return one column, which means we can load the latest login date as a column on the User model using Eloquent's addSelect() method. Since we're only returning one column, we need to order the login activities by the latest and take the first record.

✅ DO

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

$lastLoginAt = $user->last_login_at;

With this subquery, we have eliminated the eager load query, and we no longer need to load all the login activities of a user. Now, even when we are loading multiple user records, it will still be just a single query pulling records from two different tables.

Dynamic Relationships Using Subqueries

With our last login implementation, we are only able to work with a single column from the Login table. If we want to work with more columns from the Login table, then our subqueries won't be enough, as we would have to keep adding more subqueries for every new column we want to retrieve.

For example, if we need more information like the login IP address, device type, and browser, our query will look like this:

❌ DONT

$user = User::query()
            ->addSelect(['last_login_at'=> Login::select('created_at')
                ->whereColumn('user_id', 'users.id')
                ->latest()
                ->take(1)
            ])
            ->addSelect(['ip_address'=> Login::select('ip_address')
                ->whereColumn('user_id', 'users.id')
                ->latest()
                ->take(1)
            ])
            ->addSelect(['browser'=> Login::select('browser')
                ->whereColumn('user_id', 'users.id')
                ->latest()
                ->take(1)
            ])
            ->first();

$lastLoginAt = $user->last_login_at;
$ipAddress = $user->ip_address;
$browser = $user->browser;

Even if we decide to extract this as a scope, it still makes it a chore each time we add additional information from the last login activity record.

With a subquery, we can define a dynamic relationship between the Login and User model. This relationship is dynamic because the column that connects these two tables is defined using a subquery at runtime.

For example, now that we want to load the last login as a model, we have to define a relationship in our User model that will return a Login model, and that will be a BelongsTo relationship.

class User extends Model
{
    public function lastLoginActivity(): BelongsTo 
    {
        return $this->belongsTo(Login::class)
    }
}

For this relationship to work, our users table must have last_login_id as foreign key column. However, we can use a subquery to provide that column without having to normalize the users table. With a subquery, we can provide the foreign key column, and with eager loading, we can load our dynamic relationship lastLoginActivity using the last_login_id column provided by the subquery. It looks like this:

✅ DO

$user = User::addSelect(['last_login_id'=> Login::select('id')
            ->whereColumn('user_id', 'users.id')
            ->latest()
            ->take(1)
        ])
        ->with('lastLoginActivity')
        ->first();

$lastLoginAt = $user->last_login_at;
$ipAddress = $user->ip_address;
$browser = $user->browser;

Subqueries Using Eloquent Scope

With Eloquent scope, we can clean up our subqueries and make them reusable. Let's start by adding a scope on our User model for the last_login_at column in our first subquery example.

class User extends Model
{
    public function scopeWithLastLoginAt(Builder, $query): BelongsTo 
    {
        $query->addSelect(['last_login_at'=> Login::select('created_at')
            ->whereColumn('user_id', 'users.id')
            ->latest()
            ->take(1)
        ]);
    }
}

Now, we use this scope whenever we want to include the last_login_at column for a user. Like this:

$user = User::withLastLoginAt()->first();

$lastLoginAt = $user->last_login_at;

For our second subquery example, we can do the same thing by creating a scope that eager loads our lastLoginActivity dynamic relationship like this:

class User extends Model
{
    public function scopeWithLastLoginActivity(Builder, $query): BelongsTo 
    {
        $query->addSelect(['last_login_id'=> Login::select('id')
            ->whereColumn('user_id', 'users.id')
            ->latest()
            ->take(1)
        ])->with('lastLoginActivity');
    }
}

Now, we can load our dynamic relationship like this:

$user = User::withLastLoginActivity()->first();

$lastLoginAt = $user->last_login_at;
$ipAddress = $user->ip_address;
$browser = $user->browser;

Circular Relationships Using Chaperone()

All relationships point to each other, which is another way of saying that all relationships are circular. For example, if we have a Book and a Category model, we know that a book belongs to a category, and a category could have many books.

Circular relationships usually result in N+1 queries, which happens when we try to access the parent model from a child model while looping through the child models, like this:

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

foreach ($categories as $category) {
    foreach ($category->books as $book) {
        echo $book->category->title; // N+1 queries because we are lazy loading the category relas.
    }
}

Now we have another N+1 query problem because we are lazy loading category, the parent relationship of our Book model.

Laravel 11 introduced a chaperone() method that solves this problem by hydrating the parent models on the children without loading them from the database again. We can call the chaperone() method at runtime like this:

$categories = Category::with(['books' =>  fn ($books) => $books->chaperone()])->get();

We can also call the chaperone() method when we define the child relationship. In our book category example, it looks like this:

class Category extends Model
{
    public function books(): HasMany
    {
        return $this->hasMany(Book::class)->chaperone();
    }
}

Using Indexes

Indexes are special lookup tables used by the database search engine to speed up data retrieval.

Column indexing is a concept that improves the performance of any application connected to a database, and Laravel provides an elegant way to add indexes, but it doesn't tell us where and when to add one.

The basic principle of indexing is to always index columns that are frequently queried, used for sorting and ordering, used in WHERE clauses, and used in JOIN statements.

Let me show you a few things you need to look out for when thinking about indexing.

Finding Possible Indexes Using SQL EXPLAIN Statement

The EXPLAIN statement provides information about how the database engine executes queries, including where we should add indexes to a table to make the query execute faster.

Laravel's Query & Eloquent builders provide an explain() method that allows us to dump the SQL query and examine its execution. Here is an example:

Category::select(['id', 'name'])->explain()->dd();

The output looks like this:

array:1 [
  0 => {#3708
    +"id": 1
    +"select_type": "SIMPLE"
    +"table": "categories"
    +"partitions": null
    +"type": "ALL"
    +"possible_keys": null
    +"key": null
    +"key_len": null
    +"ref": null
    +"rows": 29
    +"filtered": 10.0
    +"Extra": "Using where"
  }
]

From the array output of our EXPLAIN statement, we need to pay attention to the possible_keys item, which shows if any indexed column could be used, and the key item, which shows the indexed column that was actually used in the query. This command is also availabe for both MySQL and PostgreSQL.

Multi-Column Indexing

Multi-column indexing is very effective when multiple columns are queried in the same WHERE clause. For example, when performing a search on the first_name and last_name columns of our User model, the query might look like this:

$search = 'Jude';

$result = User::where(
        fn (Builder $query) => $query
            ->where('first_name', "$search%")
            ->orWhere('last_name', "$search%")
    )->get();

Defining individual indexes on these columns will have no effect on this query. To optimize such a query, we need to create a multi-column index on the columns in the WHERE clause—in this case, the first_name and last_name columns. We need to do this inside our users table migration file, like this:

// migration 
Schema::create('users', function (Blueprint $table) {
    ...
    $table->index(['first_name', 'last_name']);
});

Now that we’ve created a multi-column index, it’s important to note that the WHERE clause must follow the exact order in which the index was created. For example, if we reverse the WHERE clause to start with the last_name column, our multi-column index may not be used effectively.

Using Unions to Run Queries Independently

When querying multiple tables in a single SELECT query, such as a search query that matches a term in two different tables, using UNION can produce faster results. This is because a UNION query consists of individual queries, each of which can take advantage of the indexes on the individual tables.

For example, if we have an employees table and a companies table with over 100,000 records, where each employee belongs to a company, and we want to perform a search to match either the employee's name or the company's name (with search columns indexed respectively), our first approach, using subqueries, would look like this:

❌ DONT

$searchTerm = 'taylor';

Employee::with('company')
    ->where(fn($query) => $query
    ->where('name', 'LIKE', "$searchTerm%")
    ->orWhereIn('company_id', fn($query) => $query
        ->select('id')
        ->from('companies')
        ->where('name', 'LIKE', "$searchTerm%")))
    ->get()

The problem with our subquery approach is that it doesn't make use of only the employee's name column index on the employees table, which in turn makes our query slow because the subquery for the company's name search is not make use of the name column index. Testing this over 200,000 records on my 2021 M1 MacBook Pro, these queries—although they are just two queries—took over 300ms.

Our second approach would be to run these queries independently. Instead of using a subquery, we would run a separate query for the company's name search and append the result to our employee query like this:

✅ DO 

Employee::with('company')
    ->where(fn($query) => $query
        ->where('name', 'LIKE', "$searchTerm%")
        ->orWhereIn('company_id', Company::where('name', 'LIKE', "$searchTerm%")->pluck('id'))) // independent query
    ->get()

This query performs better than our first query because it leverages the employee's name column index, while the independent query for the employee's company name search also benefits from the name column index on the companies table. However, this approach introduces a third query, even though it improved our response time, reducing it from over 300ms to 4ms.

Our third approach is to use UNION to run this query independently as a single operation. The goal is to reduce the number of queries while making use of the indexes defined on these columns. Our UNION query looks like this:

✅  DO

$employeeQuery = DB::query()
    ->select('id')
    ->from('employees')
    ->where('name', 'LIKE', "$searchTerm%");

$companyQuery = DB::query()
    ->select('employees.id')
    ->from('employees')
    ->join('companies', 'companies.id', '=', 'employees.company_id')
    ->where('companies.name', 'LIKE', "$searchTerm%");

Employee::with('company')
    ->whereIn('id', fn (Builder $query) => $query
        ->select('id')
        ->from(table: $employeeQuery->union($companyQuery), as: 'matches'))
    ->get();

With our UNION query, we've reduced the number of queries to two, and all of our indexes are being utilized. Most importantly, we've improved our response time, reducing the query duration from 4ms to 2ms.

How To Avoid N+1 Query Completely

To avoid N+1 queries in your Laravel applications, especially during development, we can disable lazy loading. This can be done in a way that doesn't affect the production environment by adding the snippet below to the AppServiceProvider's boot method:

public function boot()
{
    Model::preventLazyLoading(! app()->isProduction());
}

This will throw an exception whenever lazy loading is introduced in our queries during development.

Appendix

You can reach out to me on X(formerly Twitter) if you have any questions.

Join my inner circle newsletter

Be the first to hear about anything I publish, launch, or think is helpful for you. Subscribe here

Hey, have you tried Litehost lately ?

Litehost is a web hosting platform for PHP & Laravel developers with Composer, Git, PHP & CLI pre-installed. Try it now