Laravel Large Data — chunk, cursor, lazy
LaravelLaravel 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?
| Method | Memory | Best For | Supports Collection Methods |
|---|---|---|---|
all() | ❌ Very High | Small datasets only (<1,000) | ✅ Yes |
chunk() | ✅ Low | Processing batches, sending emails | ✅ Per batch |
chunkById() | ✅ Low | Updating/deleting records safely | ✅ Per batch |
cursor() | ✅✅ Very Low | Read-only processing, exports | ❌ No |
lazy() | ✅✅ Very Low | Read-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.