Cursor Pagination &amp; Lazy Collections in Laravel | 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 and Lazy Collections at Scale in Laravel        On this page       1. [  Why Offset Pagination Fails at Scale ](#why-offset-pagination-fails-at-scale)
2. [  Cursor Pagination ](#cursor-pagination)
3. [  What the Generated SQL Looks Like ](#what-the-generated-sql-looks-like)
4. [  Caveats ](#caveats)
5. [  Lazy Collections for Batch Processing ](#lazy-collections-for-batch-processing)
6. [  Combining lazy() with Collection Pipelines ](#combining-lazy-with-collection-pipelines)
7. [  lazyById() for Long-Running Processes ](#lazybyid-for-long-running-processes)
8. [  Choosing the Right Tool ](#choosing-the-right-tool)
9. [  Key Takeaways ](#key-takeaways)

  ![Cursor Pagination and Lazy Collections at Scale in Laravel](https://cdn.msaied.com/536/3aab48ef4a4eaa26a3267637dc2ec8c7.png)

  #laravel   #eloquent   #performance   #pagination  

 Cursor Pagination and Lazy Collections at Scale in Laravel 
============================================================

     11 Aug 2026      3 min read    ![Mohamed Said](https://cdn.msaied.com/01KT78WE565VEMM3PSNQAAB0MJ.jpg)  Mohamed Said  

       Table of contents

  9 sections  

1. [  01   Why Offset Pagination Fails at Scale  ](#why-offset-pagination-fails-at-scale)
2. [  02   Cursor Pagination  ](#cursor-pagination)
3. [  03   What the Generated SQL Looks Like  ](#what-the-generated-sql-looks-like)
4. [  04   Caveats  ](#caveats)
5. [  05   Lazy Collections for Batch Processing  ](#lazy-collections-for-batch-processing)
6. [  06   Combining lazy() with Collection Pipelines  ](#combining-lazy-with-collection-pipelines)
7. [  07   lazyById() for Long-Running Processes  ](#lazybyid-for-long-running-processes)
8. [  08   Choosing the Right Tool  ](#choosing-the-right-tool)
9. [  09   Key Takeaways  ](#key-takeaways)

       Why Offset Pagination Fails at Scale
------------------------------------

Every time you call `->paginate(50)` with `OFFSET 50000`, the database scans and discards 50,000 rows before returning your page. On a table with millions of records, that cost compounds with every page request. Query time grows linearly, index scans become full-table scans, and your users notice.

Laravel ships two better tools for this: **cursor pagination** for user-facing pages and **lazy collections** for background processing.

---

Cursor Pagination
-----------------

Cursor pagination encodes the last-seen row's ordered column value into an opaque token. The next query uses a `WHERE` clause instead of `OFFSET`, which the database can satisfy with a simple index seek.

```php
// Controller
$orders = Order::query()
    ->where('tenant_id', $tenantId)
    ->orderBy('created_at')
    ->orderBy('id') // tie-breaker — must be unique
    ->cursorPaginate(50);

return OrderResource::collection($orders);

```

The response includes `next_cursor` and `prev_cursor` tokens. Pass them back as `?cursor=` and Laravel decodes them automatically.

### What the Generated SQL Looks Like

```sql
-- First page
SELECT * FROM orders
WHERE tenant_id = 1
ORDER BY created_at ASC, id ASC
LIMIT 51;

-- Second page (cursor decoded)
SELECT * FROM orders
WHERE tenant_id = 1
  AND (created_at > '2024-06-01 12:00:00'
    OR (created_at = '2024-06-01 12:00:00' AND id > 9823))
ORDER BY created_at ASC, id ASC
LIMIT 51;

```

The composite `(tenant_id, created_at, id)` index satisfies this seek in microseconds regardless of how deep into the dataset you are.

### Caveats

- Cursor pagination **cannot jump to an arbitrary page** — it is forward/backward only.
- Your `orderBy` columns must be **stable and unique** (always add `id` as a tie-breaker).
- Avoid nullable columns in the cursor key; NULL comparisons break the seek logic.

---

Lazy Collections for Batch Processing
-------------------------------------

When you need to process every row — exports, re-indexing, data migrations — `chunk()` is the classic approach, but it fires a new query per chunk and holds an entire chunk in memory. `lazy()` streams rows through a PHP generator, keeping memory flat.

```php
// Bad: loads 1,000 rows into memory, then another 1,000, etc.
Order::where('status', 'pending')->chunk(1000, function ($orders) {
    $orders->each(fn ($o) => dispatch(new ProcessOrder($o)));
});

// Good: one query, cursor-driven, constant memory
Order::where('status', 'pending')
    ->lazy()
    ->each(fn ($order) => dispatch(new ProcessOrder($order)));

```

Under the hood, `lazy()` uses `PDO::FETCH_LAZY` via a cursor, pulling one row at a time from the database driver buffer.

### Combining lazy() with Collection Pipelines

```php
Order::where('status', 'pending')
    ->lazy()
    ->filter(fn ($o) => $o->total > 100)
    ->map(fn ($o) => new ProcessOrder($o))
    ->pipe(fn ($jobs) => Bus::batch($jobs->all())->dispatch());

```

Because `LazyCollection` is a generator-backed collection, `filter` and `map` are also lazy — nothing is evaluated until `all()` forces iteration.

### lazyById() for Long-Running Processes

If your process modifies rows mid-iteration (e.g., updating `status`), the cursor can drift. Use `lazyById()` instead — it re-queries in chunks ordered by primary key, safe against mutations:

```php
Order::where('status', 'pending')
    ->lazyById(500, 'id')
    ->each(function ($order) {
        $order->update(['status' => 'processing']);
    });

```

---

Choosing the Right Tool
-----------------------

| Scenario | Tool | |---|---| | User-facing paginated API | `cursorPaginate()` | | Read-only export / reporting | `lazy()` | | Mutating rows during iteration | `lazyById()` | | Random page access required | `paginate()` (accept the cost) |

---

Key Takeaways
-------------

- `OFFSET` pagination degrades linearly; cursor pagination uses index seeks and stays fast at any depth.
- Always include a unique tie-breaker column in your `orderBy` for cursor pagination.
- `lazy()` streams rows via a generator — memory stays constant regardless of result set size.
- Use `lazyById()` when the loop body mutates the rows being iterated.
- A composite index covering your filter + order columns is non-negotiable for both techniques.

 Found this useful?

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

 Frequently Asked Questions 
----------------------------

  3 questions  

     Q01  Can I use cursor pagination with complex WHERE clauses or joins?        Yes, cursor pagination works with any Eloquent query. The only constraint is that the columns in your `orderBy` calls must be deterministic and covered by an index. Joins are fine as long as the ordered columns remain unambiguous — prefix them with the table name if needed. 

      Q02  Does lazy() hold an open database connection for the entire iteration?        Yes. The underlying PDO cursor keeps the connection open until the generator is exhausted or garbage-collected. For very long-running jobs this is usually acceptable, but if you need to release the connection mid-process, switch to `lazyById()` which closes and reopens the connection between chunks. 

      Q03  Is cursorPaginate() compatible with Laravel API Resources?        Fully. `CursorPaginator` implements the same `Arrayable` and `JsonSerializable` contracts as `LengthAwarePaginator`. Wrap it in `YourResource::collection($paginator)` and the JSON response will include `data`, `next_cursor`, `prev_cursor`, and `per_page` automatically. 

  Continue reading

 More Articles 
---------------

 [ View all    ](https://msaied.com/articles) 

 [ ![Livewire v3.8.4 Released: Octane Memory Leak Fix and Fetch Redirect Handling](https://cdn.msaied.com/534/fdb2d91db2cb26fba0788d205b663031.png) livewire laravel octane 

### Livewire v3.8.4 Released: Octane Memory Leak Fix and Fetch Redirect Handling

Livewire v3.8.4 ships two important backports: a fix for a computed property listener memory leak under Larave...

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

 10 Aug 2026     3 min read  

  Read    

 ](https://msaied.com/articles/livewire-v384-released-octane-memory-leak-fix-and-fetch-redirect-handling) [ ![Laravel artisan dev: Run Server, Queue, Logs, and Vite in One Command](https://cdn.msaied.com/533/88ab98460b08aed42d6688eaa02a9620.png) Laravel Artisan Laravel 13 

### Laravel artisan dev: Run Server, Queue, Logs, and Vite in One Command

Laravel 13.16 introduced a first-party `php artisan dev` command that replaces the old Composer script, runnin...

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

 10 Aug 2026     3 min read  

  Read    

 ](https://msaied.com/articles/laravel-artisan-dev-run-server-queue-logs-and-vite-in-one-command) [ ![PostgreSQL JSONB in Laravel: Indexing, Querying, and Casting Without the Pain](https://cdn.msaied.com/532/0c1c122849d3f997950ffca44076f86c.png) laravel postgresql eloquent 

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

JSONB columns unlock flexible schemas in PostgreSQL, but raw queries get ugly fast. Learn how to index, query,...

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

 10 Aug 2026     4 min read  

  Read    

 ](https://msaied.com/articles/postgresql-jsonb-in-laravel-indexing-querying-and-casting-without-the-pain-1) 

   [  ![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)
