Use chunkById when processing rows that change during the loop
A cleanup job reads unprocessed rows in chunks and marks each chunk processed. Offset pagination can skip rows because the set shrinks between queries. The second offset is applied to a different result set.
Advance using a stable key
DB::table('imports')->where('processed', false)
->chunkById(100, function ($rows) {
foreach ($rows as $row) {
// Apply an idempotent operation for this row.
DB::table('imports')->where('id', $row->id)->update(['processed' => true]);
}
});
This fragment illustrates traversal, not a complete concurrent-worker design. chunkById advances past the last key rather than skipping an offset into the changing filtered set. Do not mutate the traversal key inside the callback.
Decide who owns each row
Two workers can still select the same unprocessed rows. If concurrent consumers are expected, add an appropriate claiming strategy and recovery state. A chunking method is a memory and traversal tool, not a work-distribution lock.
Keep the operation idempotent and record failures without incorrectly marking them processed. If external calls occur, avoid wrapping the entire large batch in a long database transaction merely for convenience.
Test with more rows than one chunk, including failures near a chunk boundary. Assert every intended ID is processed exactly according to your duplicate policy. A fixture of three rows with a chunk size of one hundred never exercises the pagination behavior that caused the original bug.