chunkById() vs chunk(): Safely Iterating Huge Eloquent Tables
Model::chunk() is the go-to for looping over large tables without loading everything into memory at once. But it has a sharp edge that catches a lot of Laravel developers: if you update or delete rows inside the loop, chunk() can skip records or process the same one twice.
Why chunk() breaks under mutation
Order::where('status', 'pending')->chunk(200, function ($orders) {
foreach ($orders as $order) {
$order->update(['status' => 'processed']);
}
});
chunk() works by running LIMIT 200 OFFSET n queries under the hood. Once you update the first 200 rows so they no longer match status = 'pending', the next page's OFFSET 200 query is now looking at a table where those rows have shifted out of the result set. Depending on timing, you can end up skipping rows that should have been processed.
chunkById() fixes this by using a cursor
Instead of OFFSET, chunkById() filters on the primary key of the last row it saw:
Order::where('status', 'pending')->chunkById(200, function ($orders) {
foreach ($orders as $order) {
$order->update(['status' => 'processed']);
}
});
Under the hood this generates queries like WHERE id > ? ORDER BY id LIMIT 200, so it doesn't matter that rows are being updated out from under it. Each page is defined by "everything after the highest id I've already seen," which stays correct even as the status column changes underneath the query.
When chunkById() isn't enough on its own
If your update changes the column you're ordering or filtering by in a way that reorders IDs (rare, but possible with custom sort columns), pass the column explicitly:
Order::orderBy('priority')
->chunkById(200, function ($orders) {
// ...
}, column: 'id', alias: 'id');
The column and alias parameters let you tell chunkById() which column to use as the cursor when it differs from the table's primary key, which matters most when you're joining tables and the natural primary key is ambiguous.
Deleting rows in a loop
Deletion has the same trap, and Laravel gives you chunkByIdDesc() for it:
LogEntry::where('created_at', '<', now()->subYear())
->chunkByIdDesc(500, function ($logs) {
$logs->each->delete();
});
Walking backward from the highest ID means deleted rows never shift the position of rows you haven't processed yet.
The rule of thumb
Use chunk() only for read-only iteration where nothing in the loop changes the result set. The moment you update, delete, or otherwise mutate rows that affect the query's WHERE clause, switch to chunkById(). It costs nothing extra and saves you from a bug that's genuinely painful to track down, since it usually only shows up on production-sized tables, not your local seeder data.