Model::chunk

Processes query results in fixed-size batches so large tables never load fully into memory.

Signature
Model::chunk(int $count, callable $callback): bool

Parameters

ParameterTypeDescription
$$countintHow many models to load per batch. Each batch costs one query.
$$callbackcallable(Illuminate\Database\Eloquent\Collection<int, TModel>, int): mixedInvoked once per batch with the collection of models and the one-based page number. Returning false stops the iteration.

Return value

boolTrue when every chunk was processed, false when the callback returned false and stopped the iteration early.

Errors

chunkById() throws RuntimeException when the ordering column is missing from the selected columns, since it cannot read the last id to continue from.

Changelog

  • PHP 12.0.0Chunked queries now honour a limit() and offset() set on the query instead of overwriting them.

chunk runs the query repeatedly with a moving LIMIT and OFFSET, handing each batch of models to your callback and discarding it before fetching the next. Only one batch is in memory at a time, which is what makes iterating a multi-million row table possible.

When to reach for it

Use it in commands, jobs and migrations that must touch every row: backfilling a column, sending a mail per record, exporting to a file. It is the fix for the fatal memory error that Model::all() produces on a large table.

What trips people up

If the callback modifies a column the query filters on, the result set shifts underneath the offsets and rows get skipped. Updating departed while filtering where('departed', true) means every batch renumbers what page two contains — you silently process roughly half the table. chunkById avoids this by tracking the last primary key and adding where id > ? instead of using an offset.

Since chunkById adds its own where, group your own orWhere conditions inside a closure or they will bind loosely and match far more than intended.

Unlike the DB query builder, an Eloquent chunk without an orderBy does not throw; the primary key ordering is added for you.

Example

Example · php
<?php

use App\Models\Flight;
use Illuminate\Database\Eloquent\Collection;

$processed = 0;

$completed = Flight::chunk(200, function (Collection $flights, int $page) use (&$processed) {
    foreach ($flights as $flight) {
        $processed++;
    }

    echo "page {$page}: {$flights->count()} rows\n";
});

// page 1: 200 rows
// page 2: 200 rows
// page 3: 143 rows

echo $processed;          // 543
var_dump($completed);     // bool(true)

When to use it

  • Backfill a new column across an existing table from an Artisan command.
  • Send one email per subscriber without holding every subscriber in memory.
  • Export a large table to CSV in batches.
  • Recalculate a denormalised counter for every row nightly.

Frequently asked questions

Why does chunk() skip records in Laravel?

Because the callback is updating a column the query filters on, so rows leave the result set while the loop is still advancing by offset. Switch to chunkById(), which continues from the last primary key instead of counting offsets.

What is the difference between chunk, chunkById and lazy?

chunk pages with LIMIT and OFFSET, chunkById pages with a where clause on the primary key and is safe when you modify the rows, and lazy batches like chunk but returns a flat LazyCollection instead of invoking a callback per batch.

Does chunk() need an orderBy clause?

Not on an Eloquent query — Laravel adds an ascending order on the qualified primary key automatically. The DB query builder version does throw a RuntimeException if you have not specified one.

More examples

Why chunkById exists

The first form leaves roughly half the table untouched and reports success, which is why the bug usually surfaces as "the backfill only worked for some rows". chunkById adds where id > {last id of previous batch}, so shrinking the result set cannot shift anything the loop has yet to reach.

Example · php
<?php

use App\Models\Flight;
use Illuminate\Database\Eloquent\Collection;

// WRONG: the callback writes to the column the query filters on.
// After batch 1 clears 200 rows, the rows that were on page 2
// have moved to page 1 — and page 2 is fetched next. Half are skipped.
Flight::where('departed', true)
    ->chunk(200, function (Collection $flights) {
        $flights->each->update(['departed' => false]);
    });

// RIGHT: continue from the last primary key instead of an offset.
Flight::where('departed', true)
    ->chunkById(200, function (Collection $flights) {
        $flights->each->update(['departed' => false]);
    }, column: 'id');

echo Flight::where('departed', true)->count();  // 0

Grouping your own conditions

chunkById appends its own where id > ? to the query. Without the wrapping closure the SQL becomes "delayed = 1 or cancelled = 1 and id > 200", and AND binds tighter than OR, so every delayed flight matches on every pass. The closure forces the parentheses that keep your conditions together.

Example · php
<?php

use App\Models\Flight;
use Illuminate\Database\Eloquent\Collection;

Flight::where(function ($query) {
    $query->where('delayed', true)->orWhere('cancelled', true);
})->chunkById(200, function (Collection $flights) {
    $flights->each->update([
        'departed' => false,
        'cancelled' => true,
    ]);
}, column: 'id');

echo Flight::where('cancelled', true)->count();  // 812

Stopping early, and the lazy alternative

Returning false from the callback breaks out of the loop and makes chunk() return false, which is how you distinguish an early exit from a full pass. lazy() batches identically but yields a LazyCollection you can foreach over, which reads better when there is no natural per-batch work such as a bulk insert.

Example · php
<?php

use App\Models\Flight;
use Illuminate\Database\Eloquent\Collection;

$found = null;

$completed = Flight::chunk(200, function (Collection $flights) use (&$found) {
    foreach ($flights as $flight) {
        if ($flight->name === 'London to Paris') {
            $found = $flight;

            return false;   // stop iterating
        }
    }
});

var_dump($completed);   // bool(false) — stopped early
echo $found->name;      // London to Paris

// Same batching, but a flat stream instead of a callback.
foreach (Flight::lazy(200) as $flight) {
    echo $flight->name . "\n";
}

Discussion

  • Be the first to comment on this lesson.