Lessons → Performance → Laravel Large Data — chunk, cursor, lazy

Laravel Large Data — chunk, cursor, lazy

Laravel
⏱ 25 min read📖 IntermediateNot completed

Laravel provides powerful built-in tools for handling large datasets without running out of memory. This lesson covers chunk(), cursor(), lazy(), chunkById() and when to use each one.

The Problem — Real Example

The Wrong Way
<?php
// You have 500,000 users in your database
// This will CRASH with memory error!

$users = User::all(); // Loads ALL 500,000 users into RAM

foreach ($users as $user) {
    $user->sendMonthlyReport();
}

// Error: Allowed memory size of 134217728 bytes exhausted
?>

Option 1: chunk() — Process in Batches

Loads N records at a time, processes them, then loads the next N:

Laravel — chunk()
<?php
// Process 1000 users at a time
User::chunk(1000, function ($users) {
    foreach ($users as $user) {
        $user->sendMonthlyReport();
    }
    // After this batch, memory is freed automatically
});

// With query constraints
User::where('active', true)
    ->orderBy('id')
    ->chunk(500, function ($users) {
        foreach ($users as $user) {
            Mail::to($user)->send(new NewsletterMail());
        }
    });

// Stop processing early by returning false
User::chunk(1000, function ($users) {
    foreach ($users as $user) {
        if ($user->hasError()) {
            return false; // Stop chunking
        }
        $user->process();
    }
});
?>

Option 2: chunkById() — Safer for Updates

chunk() can miss records if you update data during chunking. chunkById() is safer:

Laravel — chunkById()
<?php
// PROBLEM with chunk() when updating:
// chunk() uses OFFSET which can skip records when rows are deleted/updated

// SOLUTION — chunkById() uses WHERE id > last_id instead of OFFSET
User::chunkById(1000, function ($users) {
    foreach ($users as $user) {
        $user->update(['points' => $user->points + 10]);
        // Safe! chunkById won't skip records even when updating
    }
});

// With custom column
Order::chunkById(500, function ($orders) {
    foreach ($orders as $order) {
        $order->recalculateTotal();
    }
}, column: 'id'); // specify the column to chunk by
?>

Option 3: cursor() — Most Memory Efficient

Uses a database cursor — only ONE model in memory at a time:

Laravel — cursor()
<?php
// cursor() uses a PHP Generator under the hood
// Only 1 record in memory at any time!

foreach (User::cursor() as $user) {
    $user->generateReport();
}

// With constraints
foreach (User::where('active', true)->cursor() as $user) {
    $user->sendEmail();
}

// Memory comparison with 100,000 users:
// User::all()    → ~200MB RAM
// chunk(1000)    → ~10MB RAM (1000 at a time)
// cursor()       → ~2MB RAM  (1 at a time!)
?>

Option 4: lazy() — Lazy Collections

Combines the power of cursor() with Laravel Collection methods:

Laravel — lazy()
<?php
// lazy() returns a LazyCollection — use Collection methods on huge datasets!
User::lazy()->each(function ($user) {
    $user->sendEmail();
});

// Use Collection methods — filter, map, etc. without loading all into memory
User::lazy()
    ->filter(fn($user) => $user->isActive())
    ->each(fn($user) => $user->sendReport());

// lazyById() — safer version like chunkById
User::lazyById()->each(fn($user) => $user->process());

// Real example — generate CSV for millions of orders
Order::where('status', 'completed')
    ->lazy()
    ->each(function ($order) {
        exportOrderToCSV($order);
    });
?>

Comparison — Which to Use When?

MethodMemoryBest ForSupports Collection Methods
all()❌ Very HighSmall datasets only (<1,000)✅ Yes
chunk()✅ LowProcessing batches, sending emails✅ Per batch
chunkById()✅ LowUpdating/deleting records safely✅ Per batch
cursor()✅✅ Very LowRead-only processing, exports❌ No
lazy()✅✅ Very LowRead-only + need Collection methods✅ Yes

Real World Example — Export 500,000 Orders to CSV

app/Http/Controllers/ExportController.php
<?php
public function exportOrders()
{
    return response()->streamDownload(function () {

        $handle = fopen('php://output', 'w');

        // Write CSV header
        fputcsv($handle, ['ID', 'Customer', 'Total', 'Status', 'Date']);

        // Stream 500,000 orders without memory issues!
        Order::with('customer')
            ->lazy()
            ->each(function ($order) use ($handle) {
                fputcsv($handle, [
                    $order->id,
                    $order->customer->name,
                    $order->total,
                    $order->status,
                    $order->created_at->format('Y-m-d'),
                ]);
            });

        fclose($handle);

    }, 'orders-' . now()->format('Y-m-d') . '.csv');
}
?>

Real World Example — Send Bulk Emails (Queue + Chunk)

PHP — Best Practice for Bulk Emails
<?php
// Best practice: chunk + queue jobs
// Don't send emails directly — dispatch to queue!

User::where('newsletter', true)
    ->chunk(500, function ($users) {
        foreach ($users as $user) {
            // Dispatch to queue — don't send directly!
            SendNewsletterJob::dispatch($user)
                ->delay(now()->addSeconds(rand(1, 60))); // Spread over time
        }
    });

// This way:
// ✅ No memory issues (chunk)
// ✅ No timeout issues (queue)
// ✅ Retries on failure (queue)
// ✅ Can monitor progress (Horizon)
?>

Paginate for Web Pages

PHP — Always Paginate in Controllers
<?php
// NEVER do this in a web controller:
$orders = Order::all(); // Could be millions of records!

// ALWAYS paginate:
$orders = Order::latest()->paginate(20);      // 20 per page
$orders = Order::latest()->simplePaginate(20); // Faster (no total count)
$orders = Order::latest()->cursorPaginate(20); // Most efficient for large tables

// In Blade:
// {{ $orders->links() }} — renders pagination links automatically
?>

Quick Reference — Rules to Remember

Rules
✅ Web page showing data       → paginate()
✅ Background job, bulk email  → chunk() + queue
✅ Updating/deleting records   → chunkById()
✅ Read-only export, report    → cursor() or lazy()
✅ Need Collection methods     → lazy()
❌ Never                       → all() on large tables
❌ Never                       → memory_limit = -1
💡
This is exactly the problem you experienced! When you got PHP memory errors fetching 10,000+ records — the fix is chunk() or cursor(). Never use all() or get() on large tables without a LIMIT.
💡
Rule of thumb: If a table has more than 10,000 rows — always use paginate() for web pages and chunk()/cursor() for background processing. Your server will thank you!
🧠

Test your knowledge!

Take a quiz to reinforce what you learned.

Take Quiz →