Laravel Cursor Pagination &amp; Lazy Collections at Scale | Mohamed Said        [  ![Mohamed Said](https://cdn.msaied.com/01KT78WE565VEMM3PSNQAAB0MH.png)   Mohamed Said Laravel Backend Engineer  ](https://www.msaied.com) [ Home ](https://www.msaied.com) [ Projects ](https://www.msaied.com/projects) [ Articles  ](https://www.msaied.com/articles) [ Certificates ](https://www.msaied.com/certificates) [ Contact ](https://www.msaied.com#contact-section) 

       [  ](https://github.com/EG-Mohamed)       

 [ Home ](https://www.msaied.com) [ Projects ](https://www.msaied.com/projects) [ Articles ](https://www.msaied.com/articles) [ Certificates ](https://www.msaied.com/certificates) [ Contact ](https://www.msaied.com#contact-section) 

  [ home ](https://www.msaied.com)    [ articles ](https://www.msaied.com/articles)    Cursor Pagination at Scale with Laravel Lazy Collections and Chunked Iteration        On this page       1. [  Why Offset Pagination Fails at Scale ](#why-offset-pagination-fails-at-scale)
2. [  Cursor Pagination: Stable, Index-Friendly Pages ](#cursor-pagination-stable-index-friendly-pages)
3. [  Composite Cursor Columns ](#composite-cursor-columns)
4. [  Lazy Collections: Streaming Without Loading Everything ](#lazy-collections-streaming-without-loading-everything)
5. [  Filtering and Mapping Without Materialising ](#filtering-and-mapping-without-materialising)
6. [  Chunked Iteration: Batched Processing with Isolated Connections ](#chunked-iteration-batched-processing-with-isolated-connections)
7. [  Putting It Together: Export Pipeline ](#putting-it-together-export-pipeline)
8. [  Key Takeaways ](#key-takeaways)

  ![Cursor Pagination at Scale with Laravel Lazy Collections and Chunked Iteration](https://cdn.msaied.com/168/72d002a6bc12d3a7278c39e54b7ce46a.png)

  #laravel   #eloquent   #performance   #pagination   #collections  

 Cursor Pagination at Scale with Laravel Lazy Collections and Chunked Iteration 
================================================================================

     14 Jun 2026      4 min read    ![Mohamed Said](https://cdn.msaied.com/01KT78WE565VEMM3PSNQAAB0MJ.jpg)  Mohamed Said  

       Table of contents

1. [  01   Why Offset Pagination Fails at Scale  ](#why-offset-pagination-fails-at-scale)
2. [  02   Cursor Pagination: Stable, Index-Friendly Pages  ](#cursor-pagination-stable-index-friendly-pages)
3. [  03   Composite Cursor Columns  ](#composite-cursor-columns)
4. [  04   Lazy Collections: Streaming Without Loading Everything  ](#lazy-collections-streaming-without-loading-everything)
5. [  05   Filtering and Mapping Without Materialising  ](#filtering-and-mapping-without-materialising)
6. [  06   Chunked Iteration: Batched Processing with Isolated Connections  ](#chunked-iteration-batched-processing-with-isolated-connections)
7. [  07   Putting It Together: Export Pipeline  ](#putting-it-together-export-pipeline)
8. [  08   Key Takeaways  ](#key-takeaways)

 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.

```php
// 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:

```php
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.

```php
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

```php
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()`.

```php
// 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
------------------------------------

```php
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?

          [  ](https://twitter.com/intent/tweet?url=https%3A%2F%2Fwww.msaied.com%2Farticles%2Fcursor-pagination-at-scale-with-laravel-lazy-collections-and-chunked-iteration&text=Cursor+Pagination+at+Scale+with+Laravel+Lazy+Collections+and+Chunked+Iteration) [  ](https://www.linkedin.com/sharing/share-offsite/?url=https%3A%2F%2Fwww.msaied.com%2Farticles%2Fcursor-pagination-at-scale-with-laravel-lazy-collections-and-chunked-iteration) 

 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 &gt; :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    ](https://www.msaied.com/articles) 

 [ ![Contextual Binding and Method Injection in Laravel's Service Container](https://cdn.msaied.com/170/396bdacb111411b734f3018920214c20.png) laravel service-container dependency-injection 

### Contextual Binding and Method Injection in Laravel's Service Container

Go beyond basic dependency injection. Learn how Laravel's contextual binding, tagged services, and method inje...

  ![Mohamed Said](https://cdn.msaied.com/01KT78WE565VEMM3PSNQAAB0MJ.jpg)  Mohamed Said 

 14 Jun 2026     3 min read  

  Read    

 ](https://www.msaied.com/articles/contextual-binding-and-method-injection-in-laravels-service-container) [ ![PostgreSQL JSONB in Laravel: Indexing, Querying, and Casting Without the Chaos](https://cdn.msaied.com/169/5efebaf19646869da7c6064340c7d09f.png) laravel postgresql eloquent 

### PostgreSQL JSONB in Laravel: Indexing, Querying, and Casting Without the Chaos

JSONB columns are powerful but easy to misuse. Learn how to index, query, and cast PostgreSQL JSONB data in La...

  ![Mohamed Said](https://cdn.msaied.com/01KT78WE565VEMM3PSNQAAB0MJ.jpg)  Mohamed Said 

 14 Jun 2026     3 min read  

  Read    

 ](https://www.msaied.com/articles/postgresql-jsonb-in-laravel-indexing-querying-and-casting-without-the-chaos) [ ![Job Batching, Chaining, and Rate-Limited Middleware in Laravel Queues](https://cdn.msaied.com/167/07cf94b1aa8fcead662b9cd5af47acb6.png) laravel queues jobs 

### Job Batching, Chaining, and Rate-Limited Middleware in Laravel Queues

Go beyond basic dispatching: learn how to compose Laravel job batches, build reliable chains, and throttle thr...

  ![Mohamed Said](https://cdn.msaied.com/01KT78WE565VEMM3PSNQAAB0MJ.jpg)  Mohamed Said 

 14 Jun 2026     3 min read  

  Read    

 ](https://www.msaied.com/articles/job-batching-chaining-and-rate-limited-middleware-in-laravel-queues) 

   [  ![Mohamed Said](https://cdn.msaied.com/01KT78WE565VEMM3PSNQAAB0MH.png)   Mohamed Said Laravel Backend Engineer  ](https://www.msaied.com)Senior Backend Engineer specializing in Laravel, scalable SaaS platforms, APIs, and cloud infrastructure. I build secure, high-performance web applications that help businesses grow.

Explore

- [Home](https://www.msaied.com)
- [Projects](https://www.msaied.com/projects)
- [Articles](https://www.msaied.com/articles)
- [Certificates](https://www.msaied.com/certificates)
- [Contact](https://www.msaied.com#contact-section)

Connect

- [   hello@msaied.com ](mailto:hello@msaied.com)
- [   +20 109 461 9204 ](tel:+201094619204)

© 2026 Mohamed Said. All rights reserved.

 [  ](https://github.com/EG-Mohamed) [  ](https://www.linkedin.com/in/msaiedm/) [  ](https://wa.me/201094619204) [  ](mailto:hello@msaied.com) [  ](https://drive.google.com/file/u/0/d/1MF20IPRJyzfy32mhEutjL5EpSls0w2Q8/view)
