Cursor Pagination &amp; Lazy Collections in Laravel | Mohamed Said       [Skip to content](#main)  [ ![](https://cdn.msaied.com/01KT78WE565VEMM3PSNQAAB0MH.png) Mohamed SaidLaravel Backend Engineer ](https://www.msaied.com/public) - [Home](https://www.msaied.com/public)
- [Projects](https://www.msaied.com/public/projects)
- [Articles](https://www.msaied.com/public/articles)
- [Certificates](https://www.msaied.com/public/certificates)
- [About](https://www.msaied.com/public#about)

           [  Contact](https://www.msaied.com/public#contact) Menu 

Menu
----

Close 

 - [HomeStart here](https://www.msaied.com/public)
- [ProjectsCase studies](https://www.msaied.com/public/projects)
- [ArticlesEngineering notes](https://www.msaied.com/public/articles)
- [CertificatesCredentials](https://www.msaied.com/public/certificates)
- [AboutHow I work](https://www.msaied.com/public#about)
- [ContactGet in touch](https://www.msaied.com/public#contact)

  [Start a conversation](https://www.msaied.com/public#contact) [WhatsApp](https://wa.me/201094619204) [Email](mailto:hello@msaied.com) 

 1. [Home](https://www.msaied.com/public)
2. /
3. [Articles](https://www.msaied.com/public/articles)
4. /
5. Cursor Pagination and Lazy Collections at Scale in Laravel

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

 Offset pagination breaks under large datasets. Learn how Laravel's cursor pagination and lazy collections let you iterate millions of rows without blowing memory or killing query performance.

 ![](https://cdn.msaied.com/01M22N44A70A5MC2S599JP0MPH.webp) [Mohamed Said](https://www.msaied.com/public#person) Published 11 Aug 2026 · Updated 11 Aug 2026 · 3 min read

ShareCopy linkCopied

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

  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)

 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.

- [laravel](https://www.msaied.com/public/articles?search=laravel)
- [eloquent](https://www.msaied.com/public/articles?search=eloquent)
- [performance](https://www.msaied.com/public/articles?search=performance)
- [pagination](https://www.msaied.com/public/articles?search=pagination)

 Frequently asked questions 
---------------------------

  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.

   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.

   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.

   ![Mohamed Said](https://cdn.msaied.com/01M22N44A70A5MC2S599JP0MPH.webp)About the author
----------------

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

[About](https://www.msaied.com/public#about) [GitHub ↗](https://github.com/EG-Mohamed) [LinkedIn ↗](https://www.linkedin.com/in/msaiedm/) [WhatsApp ↗](https://wa.me/201094619204) [Email Address ↗](mailto:hello@msaied.com) [My CV ↗](https://drive.google.com/file/u/0/d/1MF20IPRJyzfy32mhEutjL5EpSls0w2Q8/view)  

   [Previous articleLivewire v3.8.4 Released: Octane Memory Leak Fix and Fetch Redirect Handling](https://www.msaied.com/public/articles/livewire-v384-released-octane-memory-leak-fix-and-fetch-redirect-handling) [Next articleMock PHP Classes in Tests With the Double Library](https://www.msaied.com/public/articles/mock-php-classes-in-tests-with-the-double-library)  

   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)

 ###  Have a technical challenge?

 Tell me what you’re building. I reply within two working days.

[Start a conversation](https://www.msaied.com/public#contact) 

   Related articles
-----------------

 [ ![](https://cdn.msaied.com/740/cce86edc21eddcbdd2f2454fadaf9c70.png)  · 3 min read### The Pipeline Pattern in Laravel: Custom Pipelines Beyond Middleware

5 Oct 2026 ](https://www.msaied.com/public/articles/the-pipeline-pattern-in-laravel-custom-pipelines-beyond-middleware-1) [ ![](https://cdn.msaied.com/739/2d6897fdcdcf090613f96f72a64b8a78.png)  · 4 min read### MySQL Full-Text Search in Laravel: Indexes, Relevance Scoring, and Boolean Mode

4 Oct 2026 ](https://www.msaied.com/public/articles/mysql-full-text-search-in-laravel-indexes-relevance-scoring-and-boolean-mode) [ ![](https://cdn.msaied.com/738/073696a3fefe18bec825beec5ac658f5.png)  · 4 min read### Laravel Queue Rate-Limited Middleware: Throttling Jobs Without Losing Work

4 Oct 2026 ](https://www.msaied.com/public/articles/laravel-queue-rate-limited-middleware-throttling-jobs-without-losing-work) 

  Have a technical challenge?
----------------------------

Tell me what you’re building. I reply within two working days.

 [Discuss your project ↗](https://www.msaied.com/public#contact) 

  © 2026 Mohamed Said · Built with Laravel, meant to last.Senior Backend Engineer specializing in Laravel, scalable SaaS platforms, APIs, and cloud infrastructure. I build secure, high-performance web applications that help businesses grow.

 - [Home](https://www.msaied.com/public)
- [Articles](https://www.msaied.com/public/articles)
- [Certificates](https://www.msaied.com/public/certificates)
- [GitHub](https://github.com/EG-Mohamed)
- [LinkedIn](https://www.linkedin.com/in/msaiedm/)
- [WhatsApp](https://wa.me/201094619204)
- [Email Address](mailto:hello@msaied.com)
- [My CV](https://drive.google.com/file/u/0/d/1MF20IPRJyzfy32mhEutjL5EpSls0w2Q8/view)
- [Sitemap](https://www.msaied.com/public/sitemap.xml)
