Skip to content

Avoid orderBy with chunkById() in Laravel

September 16, 2026 • 3 min read

If you’ve written a job that updates the rows it loops over, you’ve probably switched from chunk() to chunkById(). The docs recommend it:

If you are filtering the results of the chunk method based on a column that you will also be updating while iterating over the results, you should use the chunkById method.

With chunk(), a job that marks 1,000 pending subscribers as sent only reaches 500 of them, because each update shifts the rows its OFFSET pages through. chunkById() reaches all 1,000.

Then someone asks for the newest subscribers to go first, and the job gets an orderBy():

use Illuminate\Database\Eloquent\Collection;

Subscriber::query()
    ->where('status', 'pending')
    ->orderBy('created_at', 'desc')
    ->chunkById(100, function (Collection $subscribers) {
        $subscribers->each->update(['status' => 'sent']);
    });

It finishes without an error, and 900 of the 1,000 subscribers are still pending.

Let’s look at what orderBy() does to chunkById(), and how to keep the newest-first order without it.

Comparing the orderings

I ran the loop over 1,000 subscribers, with created_at rising with the id as it does in most tables, and recorded every row the callback received. This time the callback updated nothing, so these numbers come from the ordering alone:

QueryRowsUniqueDuplicates
chunkById(100)1,0001,0000
orderBy('created_at')->chunkById(100)1,0001,0000
orderBy('created_at', 'desc')->chunkById(100)19910099
orderBy('name')->chunkById(100)22818840
orderBy('created_at', 'desc')->chunk(100)1,0001,0000

Newest first only gets through two pages. It handles the 100 newest subscribers, then 99 of them a second time, and never reaches the other 900. Sorting by a column that doesn’t follow the id, like name, skips most of the table and repeats 40 rows.

Plain chunk() with the same orderBy() sees every row, and so does oldest first. That’s probably why this doesn’t show up while you’re testing.

Why orderBy breaks chunkById()

chunkById() keeps your orderBy() and adds its own id sort after it. These are the two queries it ran for newest first:

select * from `subscribers` where `id` is not null order by `created_at` desc, `id` asc limit 100
select * from `subscribers` where `id` > 901 order by `created_at` desc, `id` asc limit 100

The first page is ids 1000 down to 901. chunkById() takes the id of the last row on that page, 901, and asks for everything after it. That’s ids 902 to 1000, the same rows minus one. The second page has fewer than 100 rows, so chunkById() treats it as the last one and stops.

The id cursor only works when rows come back in id order, and chunkById() doesn’t check that your orderBy() keeps them that way. The chunking sections of the docs don’t mention it either, on the Query Builder page or the Eloquent one.

Going newest first with chunkByIdDesc()

For newest first, use chunkByIdDesc(), which walks the ids from the highest down:

use Illuminate\Database\Eloquent\Collection;

Subscriber::query()
    ->where('status', 'pending')
    ->chunkByIdDesc(100, function (Collection $subscribers) {
        $subscribers->each->update(['status' => 'sent']);
    });

That marks all 1,000 as sent, newest first. lazyByIdDesc() does the same if you’d rather have a lazy collection. Both rely on the newest rows having the highest ids, which is true when created_at follows the id.

Don’t use orderBy('id', 'desc') with chunkById() instead. chunkById() replaces it with its own ascending order, so the job quietly runs oldest first.

For any other order, like by name, leave the chunked query unsorted and sort each chunk after you get it.

So keep chunkById() for jobs that update the rows they filter on, and don’t give it an orderBy() on another column. If the order matters, use chunkByIdDesc() or sort inside each chunk.