Cursor Pagination at Scale with Laravel Lazy Collections and Chunked Iteration
#laravel #eloquent #performance #pagination #collections

Cursor Pagination at Scale with Laravel Lazy Collections and Chunked Iteration

4 min read Mohamed Said Mohamed Said

Why Offset Pagination Fails at Scale

Every senior engineer has seen it: a LIMIT 25 OFFSET 500000 query that takes seconds because the database must scan and discard half a million rows before returning your page. Offset-based pagination is convenient but fundamentally broken for large tables.

Laravel ships with three complementary tools that, used together, eliminate this problem: cursor pagination, lazy collections, and chunked iteration. Each solves a different layer of the problem.


Cursor Pagination: Stable, Index-Friendly Pages

Cursor pagination replaces the numeric offset with an opaque pointer derived from the last seen row's ordered column(s). The query becomes a WHERE clause rather than an OFFSET, so the database can satisfy it with a simple index seek.

// routes/api.php
Route::get('/events', function (Request $request) {
    return EventResource::collection(
        Event::orderBy('id')
            ->cursorPaginate(perPage: 50, cursor: $request->cursor())
    );
});

The response includes next_cursor and prev_cursor strings. The client passes ?cursor=eyJpZCI6MTAwMH0 on the next request — no page numbers, no drift when rows are inserted mid-scroll.

Composite Cursor Columns

When ordering by a non-unique column you must include a tiebreaker:

Event::orderBy('created_at')->orderBy('id')->cursorPaginate(50);

Laravel encodes both columns into the cursor automatically. Without the tiebreaker, rows with identical created_at values can appear on multiple pages or be skipped entirely.


Lazy Collections: Streaming Without Loading Everything

LazyCollection wraps a PHP generator, pulling rows from the database one at a time (or in small internal chunks) rather than hydrating the entire result set into memory.

use Illuminate\Support\LazyCollection;

Event::query()
    ->where('processed', false)
    ->orderBy('id')
    ->lazy() // returns LazyCollection; PDO cursor under the hood
    ->each(function (Event $event) {
        ProcessEvent::dispatch($event);
    });

lazy() uses PDO::FETCH_LAZY / unbuffered queries on MySQL, so peak memory stays roughly constant regardless of table size. The trade-off: the database connection is held open for the duration of the iteration. Keep the work inside the loop fast, or dispatch jobs instead of doing heavy lifting inline.

Filtering and Mapping Without Materialising

Event::lazy()
    ->filter(fn (Event $e) => $e->score > 0.9)
    ->map(fn (Event $e) => new EventExportRow($e))
    ->each(fn (EventExportRow $row) => $exporter->write($row));

Every operator in the chain is evaluated lazily — nothing is pulled into an array until each (or toArray) forces evaluation.


Chunked Iteration: Batched Processing with Isolated Connections

When you need to dispatch jobs, write to a secondary store, or perform work that benefits from transaction batching, chunk() and chunkById() are safer than lazy().

// chunkById is preferred — it resets the query per chunk using the last seen ID
Event::where('processed', false)
    ->chunkById(500, function (Collection $events) {
        $events->each(fn (Event $e) => ProcessEvent::dispatch($e));
    });

chunkById issues a fresh query per chunk (WHERE id > :last_id LIMIT 500), so it releases the connection between batches and is safe even if rows are updated or deleted during iteration. Plain chunk() uses OFFSET internally and can skip rows when the underlying data changes — avoid it on mutable datasets.


Putting It Together: Export Pipeline

final class ExportEventsAction
{
    public function handle(ExportRequest $dto): void
    {
        Event::query()
            ->whereBetween('created_at', [$dto->from, $dto->to])
            ->orderBy('id')
            ->chunkById(1000, function (Collection $chunk) use ($dto): void {
                $rows = $chunk->map(fn (Event $e) => [
                    'id' => $e->id,
                    'payload' => $e->payload,
                    'created_at' => $e->created_at->toIso8601String(),
                ]);

                $dto->writer->writeRows($rows->all());
            });
    }
}

This pattern keeps memory flat, uses index seeks for each chunk, and composes cleanly inside a single-responsibility action class.


Key Takeaways

  • Cursor pagination replaces OFFSET with a WHERE seek — always pair it with an indexed, ordered column plus a unique tiebreaker.
  • lazy() streams rows via a database cursor; ideal for read-only pipelines where connection hold time is acceptable.
  • chunkById() re-queries per batch; safer for mutable data and long-running jobs because it releases the connection between chunks.
  • Never use plain chunk() on a dataset that changes during iteration — use chunkById() instead.
  • For API responses, cursor pagination is the correct default for any resource that can grow beyond a few thousand rows.

Found this useful?

Frequently Asked Questions

3 questions
Q01 When should I use lazy() versus chunkById()?
Use lazy() for read-only streaming pipelines where you want minimal memory overhead and the connection can stay open. Use chunkById() when you need to update or delete rows during iteration, dispatch jobs in batches, or wrap each chunk in a transaction — it releases the connection between chunks and is safe against data mutations.
Q02 Does cursor pagination work with complex WHERE clauses and JOINs?
Yes, but the cursor is derived from the ORDER BY columns, so those columns must be present and unambiguous in the final result set. Avoid ordering by computed or aliased columns that the cursor encoder cannot reliably serialize. Adding a unique tiebreaker like id as the final ORDER BY column ensures stable, gap-free pages.
Q03 Is there a performance difference between lazy() and chunkById() at the database level?
lazy() keeps a single long-running query open via an unbuffered PDO cursor, which avoids repeated round-trips but holds a connection slot. chunkById() issues N separate indexed queries, each fast due to the WHERE id > :last_id seek. For very large tables chunkById() often wins on total wall time because each query is short and the optimizer can use the primary key index optimally.

Continue reading

More Articles

View all