Skip to main content
Create your own
Lesson illustration

Efficient Data Processing with Chunking and Cursors

Hello! Welcome to the next lesson in our "Database Performance & Optimization" module.

In our last session, we dug deep into MSSQL query optimization. You learned how to design indexes to eliminate costly Sort operators, optimize GROUP BY clauses, and write SARGable queries. The focus was on making the database itself respond as quickly as possible.

Today, we shift our attention from the database to the application layer. Even if a query is perfectly optimized and runs in milliseconds, what happens when it returns millions of records? Trying to load all that data into your application at once can lead to a critical problem: memory exhaustion. This lesson will teach you how to process massive datasets in Laravel efficiently and safely, using specific methods designed to prevent your application from crashing.

The Problem: Running Out of Memory

As a developer, you've likely written code like this countless times:

// Get all users who have not received a newsletter
$users = User::where('newsletter_sent', false)->get();

foreach ($users as $user) {
    // Send newsletter...
    $user->update(['newsletter_sent' => true]);
}

This works perfectly fine for a few hundred or even a few thousand users. But imagine your users table has 5 million records. The ->get() method attempts to fetch all matching records from the database and "hydrate" them into a collection of Eloquent models, loading every single one into your server's RAM. This will almost certainly exceed PHP's memory limit, causing your script to fail with a fatal error.

Data Chunking for Large Datasets

The article from playbooks.com neatly summarizes this exact problem. Take a quick look at the first section to see a clear illustration of this memory exhaustion issue.

Read the section titled 'The Problem: Memory Exhaustion'. It provides a concise example of what not to do.

To solve this, we need a way to process the data without holding it all in memory at once. Laravel provides excellent tools for this.

Solution 1: Processing Data in Batches with chunkById

The most common solution is to process records in smaller, manageable "chunks". Instead of fetching all 5 million records, you might fetch them 200 at a time, process them, and then fetch the next 200.

The Pitfall of the Basic chunk() Method

Laravel's chunk() method does exactly this. However, it has a significant pitfall. Consider our newsletter example again, but with chunk():

// WARNING: This code has a potential bug!
User::where('processed', false)
    ->chunk(100, function ($users) {
        foreach ($users as $user) {
            $user->update(['processed' => true]); // This changes the 'WHERE' condition!
        }
    });

The chunk() method works by running paginated queries using LIMIT and OFFSET.

  • Query 1: SELECT * FROM users WHERE processed = false LIMIT 100 OFFSET 0
  • You update these 100 users to processed = true.
  • Query 2: SELECT * FROM users WHERE processed = false LIMIT 100 OFFSET 100

The problem is that the set of users where processed = false has shrunk. The offsets are now misaligned with the changing dataset, which can cause you to skip records.

The Safe Solution: chunkById

To solve this, Laravel provides the chunkById method. It is specifically designed for cases where you are modifying the records you are iterating over. Instead of using OFFSET, it fetches the next chunk based on the ID of the last record in the previous chunk.

  • Query 1: SELECT * FROM users WHERE processed = false AND id > 0 ORDER BY id ASC LIMIT 100
  • It processes users with IDs up to, say, 150.
  • Query 2: SELECT * FROM users WHERE processed = false AND id > 150 ORDER BY id ASC LIMIT 100

This approach is robust and ensures no records are skipped, as it doesn't rely on a shifting offset.

Laravel chunk() vs chunkById() Comparison
A visual comparison of Laravel's `chunk()` and `chunkById()` methods. `chunkById` is the safer and recommended method when updating records during the iteration process.

Let's dive into the official documentation and a practical guide to solidify your understanding.

Laravel Eloquent Documentation & Data Chunking Guide

The official Laravel documentation and the playbooks.com guide offer excellent explanations and examples for chunking. We'll look at them together.

First, in the Laravel documentation (resource_id: [LINK](https://laravel.com/docs/12.x/eloquent)), read the section 'Chunking Results'. Focus on the distinction it makes between chunk and chunkById. \nThen, for more practical examples, switch to the playbooks.com guide (resource_id: [LINK](https://playbooks.com/skills/noartem/skills/laravel-data-chunking-large-datasets)). Read the sections '1. Basic Chunking with chunk()' and '2. Chunk By ID for Safer Updates'. Note the example that uses a custom ID column, which is a useful feature.

To summarize: The chunkById method is your go-to tool for batch processing. It gives you a good balance between memory efficiency and functionality, as you can still use Eloquent features like eager loading on each chunk. A typical implementation would look like this:

// GOOD: Safe for updates
User::where('newsletter_sent', false)
    ->chunkById(200, function ($users) {
        // Eager load relationships for the chunk if needed
        $users->load('profile'); 

        foreach ($users as $user) {
            // Your processing logic here...
            $user->update(['newsletter_sent' => true]);
        }
    });

Solution 2: The Ultra-Efficient cursor() Method

What if your memory constraints are even tighter, or you're dealing with tens of millions of records for a simple export? While chunkById is great, it still loads 200 (or whatever your chunk size is) models into memory at a time.

For maximum memory efficiency, Laravel offers the cursor() method.

The cursor() method leverages a powerful PHP feature called generators. Instead of loading results into an array or collection, it executes a single query and then yields one Eloquent model at a time as you iterate over it. This means only one Eloquent model is ever in memory at any given moment.

// Most memory-efficient way to iterate
foreach (LogEntry::where('level', 'error')->cursor() as $log) {
    // Process one log entry at a time
    // Only this single $log object exists in memory
}

This sounds perfect, but there's a key trade-off: you cannot eager load relationships when using a cursor. Because it only hydrates one model at a time, it cannot execute the subsequent WHERE IN (...) query needed for eager loading.

Let's see what the documentation says about this.

Eloquent: Getting Started

The Laravel documentation explains the cursor method and its trade-offs clearly.

Read the section on 'Cursors'. Pay close attention to how it works internally (using generators) and its primary limitation regarding eager loading.

Choosing the Right Method: chunkById vs. cursor

You now have two powerful tools. The key is knowing when to use each one.

Method Memory Usage Eager Loading Use Case
chunkById() Moderate Yes Batch-updating records, processing data where you need related models (e.g., sending an email with user profile data).
cursor() Lowest No Simple, forward-only iteration over a very large dataset, such as exporting a CSV, or performing a calculation that doesn't require related models.

The guide from playbooks.com includes a helpful table and some optimization tips that build on what we've learned.

Data Chunking for Large Datasets

Let's look at a direct comparison and some performance tips to round out your knowledge.

Read the sections 'Choosing the Right Method' and 'Performance Optimization Tips'. The table provides a great at-a-glance reference, and the tips (like using select()) are essential for real-world performance.

One tip worth emphasizing is selecting only the columns you need. Combining this with chunkById or cursor has a huge impact. You not only reduce the data transferred from the database (which, as you know from previous lessons, can improve index usage) but also reduce the memory footprint of each Eloquent model in PHP.

// A highly optimized approach
User::select('id', 'email', 'name')
    ->where('active', true)
    ->chunkById(500, function ($users) {
        // Process a chunk of very lightweight User models
    });

Conclusion

You've now bridged the gap between database-level and application-level performance optimization. While fast queries are essential, managing the resulting data efficiently in your application code is equally important to build robust, scalable systems.

Key Takeaways:

  • Using get() or all() on large tables is a recipe for memory exhaustion.
  • chunkById() is the standard, safe method for processing records in batches, especially when you are updating them during the loop. It offers a good balance of memory usage and features.
  • cursor() offers the lowest possible memory footprint by hydrating only one model at a time, making it ideal for simple iteration over massive datasets.
  • The main trade-off for cursor() is its inability to eager load relationships.
  • Always combine these methods with select() to retrieve only the columns you need for maximum efficiency.

Up Next:

We have one more topic in our optimization module. We've learned to make queries fast and to handle large result sets. But what if we could avoid running the query at all? In the next lesson, we will explore query caching, a technique to store the results of expensive queries and reuse them, dramatically reducing database load for frequently accessed data.

Can't find a good explanation? Sign up and we'll make it for you

Sign up