The safer iterator is not the flashiest one, it is the one that keeps your result set stable while you mutate it.

You do not notice this bug in staging. You notice it halfway through a backfill, when the progress bar looks healthy, the job exits cleanly, and the numbers still do not add up.
Say you need to mark four million legacy rows as processed. Or migrate a boolean flag. Or stamp a new derived value into every user record that still has processed_at = null. The obvious Laravel code looks responsible: you are not loading everything into memory, you are processing in batches, and you even added orderBy('id') because you have been burned before. It still skips work.
That is the part people miss. The problem is usually not memory. The problem is that chunk() is allowed to keep moving through a result set that your own callback is actively changing.
The code that looks safe is the code that hurts you
This is the shape of the bug:
<?php
use App\Models\User;
User::whereNull('processed_at')
->orderBy('id')
->chunk(1000, function ($users) {
foreach ($users as $user) {
$user->update([
'processed_at' => now(),
]);
}
});Nothing about that looks reckless. In fact, it looks like the careful version on paper: small batches, stable ordering and no giant collection sitting in memory. Careful, on paper, is not the same as safe in production.
Laravel’s own documentation still warns against it. In the Eloquent docs for Laravel 13, the framework says that if you are filtering the results of chunk() based on a column that you also update while iterating, you should use chunkById() instead, because chunk() can produce unexpected and inconsistent results.
That warning is easy to wave away until you understand what is happening under the hood.
chunk() is a batching helper, not a mutation-safe iterator. Different job.
Why rows disappear even though the job never fails
Imagine eight rows match whereNull('processed_at'), and you process three at a time.
- The first query loads rows 1, 2 and 3.
- Your callback updates
processed_aton those rows, so they no longer match the original filter. - The second query asks for the next chunk.
- But the set has already shrunk.
Rows 4, 5 and 6 have now slid into the positions previously occupied by rows 1, 2 and 3. If your batching logic moves forward by position, you have already walked past them.
That is the whole bug. No exception, no deadlock and no scary stack trace. Just silent omission.
Offset-based movement is fine when the result set is stable. A dashboard page is stable enough. A report export is usually stable enough. A mutating backfill is not stable at all. Every successful update changes the answer to the question “what records are left?”
And once that happens, position stops meaning anything.
What chunkById() actually changes
Laravel’s fix is not magic. It is just a better anchor.
Instead of moving forward by row position, chunkById() moves forward by primary key. The docs describe it plainly: it will always retrieve models with an id greater than the last model in the previous chunk.
That one detail changes the failure mode completely.
If your callback flips processed_at from null to a timestamp, the row drops out of the filter, but the walk still knows where it left off because the next query is anchored to the last seen ID, not to an offset inside a shrinking set.
The safe version looks like this:
<?php
use App\Models\User;
use Illuminate\Database\Eloquent\Collection;
User::whereNull('processed_at')
->chunkById(1000, function (Collection $users) {
$users->each->update([
'processed_at' => now(),
]);
}, column: 'id');The practical difference is not “this one is optimized.” The practical difference is “this one keeps its place.”
That is why chunkById() belongs anywhere the callback mutates the same table in a way that changes the original filter. Backfills, status flips, cleanup jobs and flag migrations all live in that family.
lazyById() is often the better default for one-way jobs
A lot of people stop at chunkById(), which is fair because it solves the skipping problem. For linear backfills, I usually prefer lazyById().
Laravel documents lazy() as a chunked query that returns one flattened LazyCollection instead of forcing you into a callback per batch. It then makes the same warning: if you are filtering on a column that you will update while iterating, use lazyById() instead.
That gives you the same ID-anchored safety, but with a cleaner control flow:
<?php
use App\Models\User;
User::where(function ($query) {
$query->whereNull('processed_at')
->orWhereNull('search_vector');
})
->select(['id', 'name', 'email', 'processed_at', 'search_vector'])
->lazyById(1000, column: 'id')
->each(function (User $user) {
$user->forceFill([
'search_vector' => strtolower($user->name . ' ' . $user->email),
'processed_at' => now(),
])->save();
});That shape is easier to extend. You can filter(), map(), takeUntil(), or attach instrumentation without nesting another callback wall inside a batch callback. It reads like a stream, but it still advances by ID under the hood.
For jobs that are conceptually “walk forward once and touch every matching row,” that is usually the right mental model.
Less ceremony. Same safety.
cursor() is not the universal answer
This is where teams often over-correct.
They hear “use less memory” and jump straight to cursor(). Laravel's docs are clear about what cursor() does well and where it stops helping.
- It executes a single database query.
- It only hydrates models as you iterate them, so one model sits in memory at a time.
- It cannot eager load relationships.
That last point matters more than most people think. The job that looked memory-efficient can become query-heavy the moment you start touching related data inside the loop. And there is another catch that bites on very large scans: Laravel explicitly says cursor() will still eventually run out of memory because PHP's PDO driver caches raw query results in its buffer. The docs recommend lazy() instead when the record count gets very large.
So the decision is not “cursor is the advanced version.” It is narrower than that.
Use cursor() when:
- You want a single-query stream.
- You do not need eager loading.
- You are not relying on it as your escape hatch for a very large backfill.
That third point is the one people usually learn late.
The decision framework I actually use
This is the part worth saving.
Use chunk() when:
- The callback does not change the column set that defines the query.
- Explicit batch boundaries help with external side effects, logging or rate limits.
- A stable dataset while you process it.
Use chunkById() when:
- You are updating or deleting rows that came from the same query.
- Clear batch boundaries still matter.
- The walk needs to advance by a stable key instead of row position.
Use lazyById() when:
- The job is still mutation-safe only if it advances by ID.
- Stream-style composition is easier to work with than callback-style batching.
- You expect the code to grow a little over time. It usually does.
Use cursor() when:
- You truly want a single-query stream.
- No relationship eager loading.
- You understand that “one model in memory” is not the same thing as “infinite scan for free.”
The common mistake is picking based on memory alone. The better question is this: what makes the next row still count as “next” after my callback runs?
If your answer is “its position in the result set,” you are already in trouble.
The hidden footgun in grouped conditions
There is a second detail in Laravel’s docs that is easy to miss.
Both chunkById() and lazyById() add their own where conditions while they paginate. The documentation recommends logically grouping your own conditions inside a closure. That is not style advice. That is correctness advice.
This is the risky shape:
<?php
User::whereNull('processed_at')
->orWhereNull('search_vector')
->chunkById(1000, function ($users) {
// ...
});And this is the safer shape:
<?php
User::where(function ($query) {
$query->whereNull('processed_at')
->orWhereNull('search_vector');
})
->chunkById(1000, function ($users) {
// ...
}, column: 'id');Without the grouping, the additional paging conditions can interact with your orWhere chain in ways that stop being obvious once the query gets more complex. You may still get rows. You may even get most of the right rows. That is not the same as a trustworthy backfill.
And if a backfill is not trustworthy, it is just delayed cleanup.
What chunkById() does not save you from
This is not a “use chunkById() and forget the rest" story.
Laravel also warns that when you update or delete records inside the chunk callback, changes to the primary key or foreign keys can affect the chunk query and may result in records not being included. So the safe pattern is narrower than people think:
- Mutate business columns, flags, timestamps and derived fields freely.
- Do not mutate the traversal key that defines how the next page is found.
- If your traversal column is not
id, use the method's column argument deliberately and make sure that column is the stable walk key you actually mean.
That last point matters in old tables where “primary key” and “operational walk order” are not the same thing. The API gives you the option. You still have to choose the right anchor.
A backfill pattern that ages well
For Laravel data corrections, this is the pattern I trust most:
- Select only the columns the job needs.
- Walk by a stable key, usually with
lazyById(). - Keep each row update idempotent so the job can resume safely.
- Log progress by last processed ID, not by chunk count.
- Treat relationships carefully. If you need eager loading, prefer
lazy()or explicit chunked loads instead of forcingcursor()into a job it does not fit.
Notice what is missing: there is no obsession with the smallest memory footprint at any cost. Production jobs fail in more interesting ways than memory pressure alone. They skip work, double work, stall behind unexpected relationship loads and look complete before they are complete.
That is why iterator choice is not a micro-optimization. It is part of the job’s correctness contract.
The rehearsal that catches this before production
If you want a quick sanity check, do not benchmark the job first. Try to break its ordering.
Seed a small table where every row matches the filter. Process it in tiny batches. Then mutate the same column that decides whether the row is still eligible. If the final count says “all done” but a second query still finds untouched rows, you do not have a performance issue. You have a traversal bug.
That test is boring, which is probably why teams skip it. But it tells you more than another stopwatch run. The danger with chunk() backfills is not that they are slow. The danger is that they look correct while they quietly leave work behind.
The real rule
If your callback changes the same condition that decides what belongs in the next batch, do not paginate by position.
Use an iterator that keeps its place even after the floor moves.


