Milind Daraniya

Debugging Slow Laravel APIs: Fixing N+1 Queries and Memory Leaks

Published June 4th, 2026 9 min read

The Silent Performance Killers in Laravel APIs

In my 10 years of working with PHP and Laravel, I have seen many projects start fast but slow down to a crawl as the database grows. Usually, the frontend team gets blamed for slow loading times. But when you look at the network tab in your ReactJS or Vue frontend, you often find that a single GET request is taking 5 seconds or more. In most cases, the culprits are N+1 query problems and memory leaks in the backend API.

When we build REST APIs, Eloquent makes it very easy to fetch database records. But this simplicity comes with a cost. If you do not pay attention to how Eloquent queries the database, you will end up running hundreds of unnecessary queries for a single API request. Let us look at how we can find these bottlenecks and fix them before they affect your users.

Understanding the N+1 Query Problem

The N+1 query problem is the most common database performance issue in Laravel. It happens when you fetch a list of parent records and then loop through them to fetch their related child records. Eloquent ends up running one query to get the parent records, and then one additional query for each parent record to get its relation. If you have 100 records, you run 101 queries.

A Real-World Example of Bad Code

Imagine you are building an API endpoint to list blog posts along with their authors. You might write something like this:

$posts = Post::all();
foreach ($posts as $post) {
    $authorName = $post->author->name;
}

This looks completely harmless. But behind the scenes, Laravel is executing these queries:

SELECT * FROM posts;
SELECT * FROM users WHERE id = 1;
SELECT * FROM users WHERE id = 2;
SELECT * FROM users WHERE id = 3;
... and so on.

If your database has 500 posts, this single endpoint will hit your MySQL database 501 times! This is a disaster for performance, especially under high traffic.

How to Identify N+1 Queries

You cannot fix what you cannot see. In my projects, I use two main tools to find these hidden queries: Laravel Telescope and Clockwork.

1. Laravel Telescope

Laravel Telescope is an amazing official companion for your local development environment. It provides a complete dashboard to inspect requests, database queries, logs, and more. You can install it using composer and inspect exactly how many queries each API call triggers. Check the official Laravel Telescope GitHub repository to set it up.

2. Clockwork

Clockwork is another great browser extension and server-side integration that adds a developer tools tab to your browser. It shows database queries, execution time, and memory usage for every API request, which is perfect for debugging decoupled ReactJS frontends.

The Golden Rule: Stop Lazy Loading in Development

Instead of waiting to find N+1 queries manually, you can instruct Laravel to throw an exception whenever a relation is lazy-loaded during development. This is a practice I highly recommend. Add this code to your AppServiceProvider.php:

use Illuminate\Database\Eloquent\Model;

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

With this enabled, your local environment will crash and tell you exactly which line of code is causing the N+1 query, making it impossible to push unoptimized code to production.

How to Fix N+1 Queries with Eager Loading

The fix for the N+1 query problem is eager loading. Eager loading tells Eloquent to load all the related records up front using a single SQL query with an IN clause.

Eager Loading with with()

To fix our previous blog post example, we simply use the with() method:

$posts = Post::with('author')->get();

Now, Laravel runs only 2 queries, no matter how many posts are in the database:

SELECT * FROM posts;
SELECT * FROM users WHERE id IN (1, 2, 3, 4, ...);

To learn more about advanced eager loading techniques, check out the official Laravel Eager Loading Documentation.

Lazy Eager Loading

Sometimes you only need to load a relation conditionally. In those cases, you can load the relation after the parent collection has already been fetched using the load() method:

$posts = Post::all();
if ($request->get('include_author')) {
    $posts->load('author');
}

Tackling Memory Leaks in Laravel APIs

While N+1 queries slow down your database, memory leaks will crash your entire server. In my experience, memory leaks in Laravel usually happen when processing large datasets, such as running bulk imports, generating large Excel exports, or running long-running queue jobs.

The Trap of Loading Too Many Models

A common mistake is fetching thousands of Eloquent models at once. Each Eloquent model is a heavy PHP object that consumes memory. If you run a query like User::all() on a table with 50,000 records, PHP will quickly run out of memory and return a 500 error.

The Solution: Chunking and Cursors

To process large datasets safely, you must stream or split the data into smaller chunks.

Using chunk()

The chunk() method retrieves a small subset of records, processes them, and then fetches the next subset:

User::chunk(200, function ($users) {
    foreach ($users as $user) {
        // Process user
    }
});

This keeps memory usage low because only 200 models are loaded into memory at any given time.

Using cursor()

If you only need to loop through records without modifying them during the loop, cursor() is even better. It uses PHP generators to fetch a single database record at a time:

foreach (User::cursor() as $user) {
    // Process user
}

The trade-off here is that cursor() keeps a single active connection open to the database, so use it carefully.

The Hidden Memory Leak: Laravel Query Log

Have you ever noticed your queue jobs running out of memory after processing a few thousand records, even when using chunking? This is usually because Laravel's query log is enabled in your local or staging environment. By default, Laravel keeps a log of every query executed in memory during a request or job execution.

To fix this, you should explicitly disable the query log before running heavy background processes:

use Illuminate\Support\Facades\DB;

DB::disableQueryLog();
// Run your heavy database operations here

Common Mistakes to Avoid

  • Using count() inside loops: Writing $user->posts->count() inside a loop loads all post models into memory just to count them. Instead, use withCount('posts') in your query to let MySQL do the heavy lifting.
  • Loading unused columns: If you only need a user's name and email, do not fetch all columns. Use select('id', 'name', 'email') to save memory and reduce database load.
  • Unconditional eager loading: Eager loading relationships you do not use in every API response wastes database resources and memory. Use API resources to conditionally load relationships only when they are requested.

Best Practices for High-Performance APIs

To keep your Laravel APIs running fast under heavy load, keep these best practices in mind:

  • Always index your foreign keys: If you are querying relationships, make sure the foreign key columns in your database have proper indexes. Without indexes, even eager-loaded queries will slow down as your tables grow.
  • Use Eloquent API Resources: API Resources allow you to transform your database models into JSON responses cleanly. You can use the whenLoaded() method to ensure relations are only included in the API response if they have been eager-loaded.
  • Set memory limits for queues: Always run queue workers with a memory limit using the --memory flag (e.g., php artisan queue:work --memory=128) so that if a memory leak does happen, the worker process restarts automatically before crashing the server.

Conclusion

Optimizing Laravel APIs is not about writing complex raw SQL queries; it is about understanding how Eloquent translates your PHP code into SQL. By setting up preventLazyLoading() in your local environment, using Laravel Telescope to monitor queries, and processing large datasets with chunking or cursors, you can build fast, reliable, and memory-efficient APIs. Take some time today to audit your slow endpoints—your database and your users will thank you!

Common Questions

What is the N+1 query problem in Laravel?

It happens when your code runs one query to fetch parent records, and then runs a separate query for each parent record to fetch its related data inside a loop. This slows down your database significantly.

How does eager loading fix N+1 queries?

Eager loading tells Eloquent to fetch all related records in a single query using SQL 'IN' operators, reducing the total database calls from N+1 to just 2.

What is the difference between chunk() and cursor() in Laravel?

Chunk retrieves records in groups (e.g., 100 at a time) to save memory. Cursor uses PHP generators to fetch one record at a time, using even less memory, but keeps a single active database connection open.

Why does my Laravel queue job run out of memory?

Often because Laravel's query log is enabled, which saves every executed query in memory. Disabling the query log using DB::disableQueryLog() inside long-running jobs fixes this.