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

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

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

  [ home ](https://msaied.com)    [ articles ](https://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%2Fmsaied.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%2Fmsaied.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://msaied.com/articles) 

 [ ![Practical RAG in Laravel: pgvector, Embeddings, and Retrieval Pipelines](https://cdn.msaied.com/487/2db17c4f7927d4a229a4786c03e7b67b.png) laravel ai pgvector 

### Practical RAG in Laravel: pgvector, Embeddings, and Retrieval Pipelines

Build a production-ready retrieval-augmented generation pipeline in Laravel using pgvector, OpenAI embeddings,...

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

 30 Jul 2026     4 min read  

  Read    

 ](https://msaied.com/articles/practical-rag-in-laravel-pgvector-embeddings-and-retrieval-pipelines-2) [ ![Laravel Octane + FrankenPHP: Persistent Services, Boot-Once Singletons, and Safe State](https://cdn.msaied.com/486/7a50572cb8b282ae36063a9213f55ed3.png) laravel octane frankenphp 

### Laravel Octane + FrankenPHP: Persistent Services, Boot-Once Singletons, and Safe State

Running Laravel under FrankenPHP with Octane means your service container lives across requests. Learn which s...

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

 30 Jul 2026     4 min read  

  Read    

 ](https://msaied.com/articles/laravel-octane-frankenphp-persistent-services-boot-once-singletons-and-safe-state) [ ![Everything Announced at Laracon US 2026: Laravel Framework, Cloud & AI Updates](https://cdn.msaied.com/485/8c49e044ff45a776167065579e1c93ac.png) Laracon 2026 Laravel Cloud Laravel AI SDK 

### Everything Announced at Laracon US 2026: Laravel Framework, Cloud &amp; AI Updates

Laracon US 2026 brought major updates across the Laravel ecosystem: scale-to-zero Flex compute, managed queues...

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

 29 Jul 2026     4 min read  

  Read    

 ](https://msaied.com/articles/everything-announced-at-laracon-us-2026-laravel-framework-cloud-ai-updates) 

   [  ![Mohamed Said](https://cdn.msaied.com/01KT78WE565VEMM3PSNQAAB0MH.png)   Mohamed Said Laravel Backend Engineer  ](https://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://msaied.com)
- [Projects](https://msaied.com/projects)
- [Articles](https://msaied.com/articles)
- [Certificates](https://msaied.com/certificates)
- [Contact](https://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)
