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

Cursor Pagination, Lazy Collections, and Chunked Iteration at Scale in Laravel

4 min read Mohamed Said Mohamed Said

The Problem with Offset Pagination at Scale

Offset-based pagination (LIMIT x OFFSET y) is the default mental model for most Laravel developers. It works fine for the first few pages, but as y grows the database must scan and discard every preceding row before returning the requested slice. On a table with millions of rows, page 5000 is measurably slower than page 1 — and the gap widens with every index miss.

Laravel gives you three escape hatches: cursor pagination, lazy collections, and chunked iteration. Each solves a different problem.


Cursor Pagination

Cursor pagination replaces the numeric offset with an opaque pointer derived from the last seen row's ordered column(s). The database seeks directly to that position instead of scanning.

// Controller
$orders = Order::orderBy('id')
    ->cursorPaginate(50);

return OrderResource::collection($orders);

The response includes next_cursor and prev_cursor tokens. The client passes them back as ?cursor=eyJpZCI6NTB9.

Constraints you must respect

  • The sort column(s) must be unique or combined with a unique tiebreaker (usually id). Without uniqueness, rows can appear on multiple pages or be skipped.
  • You cannot jump to an arbitrary page number — cursors are sequential. If your UI needs "jump to page 47", cursor pagination is the wrong tool.
  • Composite cursors work but require explicit column ordering:
$orders = Order::orderBy('created_at')->orderBy('id')
    ->cursorPaginate(50);

Laravel encodes both columns into the cursor token automatically.


Lazy Collections: Streaming Results

LazyCollection wraps PHP generators to stream database rows one at a time without loading the full result set into memory.

Order::where('status', 'pending')
    ->lazy()
    ->each(function (Order $order) {
        ProcessOrder::dispatch($order);
    });

Under the hood, lazy() issues a single query and uses a PHP generator to yield each hydrated model. Memory stays flat regardless of result count.

When lazy collections bite you

Because the database cursor stays open for the duration of the iteration, long-running generators hold a connection for the entire loop. On a busy pool this can starve other requests. For truly long jobs, prefer chunk() instead.

Also, eager loading does not compose with lazy() the way you might expect:

// This does NOT batch eager-load relationships
Order::with('items')->lazy(); // still N+1 per model

Use lazyById() with manual chunked eager loading, or switch to chunk().


Chunked Iteration

chunk() issues multiple bounded queries, each fetching a fixed number of rows. It releases the connection between batches, making it safe for long-running processes.

Order::where('status', 'pending')
    ->chunkById(500, function (Collection $orders) {
        // Eager load inside the chunk — correct N+1 prevention
        $orders->load('items.product');

        foreach ($orders as $order) {
            ProcessOrder::dispatch($order);
        }
    });

chunk() vs chunkById()

Always prefer chunkById() over chunk() when mutating rows inside the callback. Plain chunk() uses OFFSET, so deleting or updating rows mid-iteration shifts the window and causes skips. chunkById() uses a keyset approach (WHERE id > ?) which is stable under mutation.


Choosing the Right Tool

| Scenario | Best fit | |---|---| | API list endpoint, sequential navigation | Cursor pagination | | Read-only streaming export / report | lazy() or lazyById() | | Background job processing with mutations | chunkById() | | Small dataset, arbitrary page jumps | Offset (paginate()) |


A Practical Export Example

class ExportOrdersCsvJob implements ShouldQueue
{
    public function handle(): void
    {
        $stream = fopen('php://temp', 'r+');

        Order::query()
            ->select(['id', 'total', 'created_at'])
            ->chunkById(1000, function (Collection $orders) use ($stream) {
                foreach ($orders as $order) {
                    fputcsv($stream, [
                        $order->id,
                        $order->total,
                        $order->created_at->toIso8601String(),
                    ]);
                }
            });

        rewind($stream);
        Storage::put('exports/orders.csv', $stream);
        fclose($stream);
    }
}

Selecting only the columns you need keeps row hydration cheap and reduces network transfer from the database.


Key Takeaways

  • Offset pagination degrades linearly; switch to cursor pagination for sequential API endpoints on large tables.
  • lazy() streams rows with a single open cursor — great for reads, risky on long jobs with connection pools.
  • chunkById() is the safest default for background processing, especially when rows are mutated during iteration.
  • Always combine chunkById() with in-chunk eager loading to eliminate N+1 queries.
  • Select only the columns you need — hydrating full models for a CSV export is wasteful.

Found this useful?

Frequently Asked Questions

3 questions
Q01 Can I use cursor pagination with complex WHERE clauses and multiple sort columns?
Yes. Laravel's cursor paginator encodes all ordered columns into the cursor token. The only hard requirement is that the combination of sort columns is unique across rows. Add `id` as a tiebreaker if your primary sort column (e.g. `created_at`) is not unique.
Q02 Why does `lazy()` still produce N+1 queries when I pass `with()` to it?
Because `lazy()` yields one model at a time via a PHP generator, there is no batch of models for Eloquent to eager-load against. Use `chunkById()` instead and call `$chunk->load('relation')` inside the callback to batch the eager load per chunk.
Q03 When should I still use plain offset `paginate()` instead of cursor pagination?
When your UI requires jumping to an arbitrary page number, or when the total count is needed for display. Cursor pagination cannot seek to page N without traversing all preceding cursors, and it does not expose a total row count.

Continue reading

More Articles

View all