Cursor Pagination &amp; Lazy Collections at Scale 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, Chunked Iteration, 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. [  Constraints to know ](#constraints-to-know)
4. [  Chunked Iteration: chunk() vs chunkById() ](#chunked-iteration-chunk-vs-chunkbyid)
5. [  Lazy Collections and lazyById() ](#lazy-collections-and-lazybyid)
6. [  Combining with Collection pipelines ](#combining-with-collection-pipelines)
7. [  Choosing the Right Tool ](#choosing-the-right-tool)
8. [  Key Takeaways ](#key-takeaways)

  ![Cursor Pagination, Chunked Iteration, and Lazy Collections at Scale in Laravel](https://cdn.msaied.com/292/a09cfdc4dcc65660fb6ada3aae3fa264.png)

  #laravel   #eloquent   #performance   #pagination   #collections  

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

     25 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  ](#cursor-pagination)
3. [  03   Constraints to know  ](#constraints-to-know)
4. [  04   Chunked Iteration: chunk() vs chunkById()  ](#chunked-iteration-chunk-vs-chunkbyid)
5. [  05   Lazy Collections and lazyById()  ](#lazy-collections-and-lazybyid)
6. [  06   Combining with Collection pipelines  ](#combining-with-collection-pipelines)
7. [  07   Choosing the Right Tool  ](#choosing-the-right-tool)
8. [  08   Key Takeaways  ](#key-takeaways)

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

Offset-based pagination (`LIMIT 100 OFFSET 50000`) forces the database to scan and discard 50,000 rows before returning your page. On a table with millions of rows and a composite index, this becomes a full or partial index scan that grows linearly with page depth. You feel it in query times that balloon from 5 ms on page 1 to 800 ms on page 500.

Laravel ships with three tools that sidestep this entirely: **cursor pagination**, **chunked iteration**, and **lazy collections**. Each solves a different problem.

---

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

Cursor pagination encodes the last-seen value of an ordered column into an opaque token. The next query uses a `WHERE` clause instead of `OFFSET`, making every page equally fast regardless of depth.

```php
$users = User::orderBy('id')->cursorPaginate(50);

// In a subsequent request
$users = User::orderBy('id')->cursorPaginate(50, ['*'], 'cursor', $request->cursor);

```

The generated SQL looks like:

```sql
SELECT * FROM users WHERE id > 84321 ORDER BY id ASC LIMIT 50;

```

That `id > 84321` predicate hits the primary key index directly — O(log n) regardless of depth.

### Constraints to know

- The cursor column **must be unique and ordered**. Composite cursors (e.g., `created_at` + `id`) work but require both columns in `orderBy`.
- You cannot jump to an arbitrary page — cursor pagination is strictly sequential.
- `cursorPaginate` returns a `CursorPaginator`, not a `LengthAwarePaginator`, so there is no total count.

```php
User::orderBy('created_at')->orderBy('id')->cursorPaginate(25);

```

This produces a stable, tie-breaking cursor even when `created_at` has duplicates.

---

Chunked Iteration: chunk() vs chunkById()
-----------------------------------------

For background jobs or exports where you need to process every row, `chunk()` is the entry point most developers reach for. But it has a subtle bug: if rows are deleted or inserted during iteration, the offset shifts and you skip or double-process records.

```php
// Dangerous with concurrent writes
User::where('active', true)->chunk(500, function ($users) {
    // process
});

```

Use `chunkById()` instead. It re-anchors each batch using the last seen primary key:

```php
User::where('active', true)->chunkById(500, function ($users) {
    foreach ($users as $user) {
        ProcessUser::dispatch($user);
    }
});

```

The generated SQL per batch:

```sql
SELECT * FROM users WHERE active = 1 AND id > 7450 ORDER BY id ASC LIMIT 500;

```

Safe under concurrent writes, index-friendly, and consistent.

---

Lazy Collections and lazyById()
-------------------------------

When you want to iterate a result set with PHP generators — pulling rows one at a time without loading the full batch into memory — reach for `lazy()` or `lazyById()`.

```php
User::where('active', true)->lazyById(500)->each(function (User $user) {
    // Hydrated one model at a time, fetched in batches of 500
    $user->sendWeeklyDigest();
});

```

Under the hood, `lazyById()` uses `chunkById()` and yields each model through a PHP generator. Your memory footprint stays flat — you hold at most one model at a time in your application layer, while the database still returns batches of 500 for network efficiency.

### Combining with Collection pipelines

Lazy collections are first-class `Enumerable` implementations, so you can chain higher-order operations without materialising the full dataset:

```php
User::lazyById(1000)
    ->filter(fn($u) => $u->isEligibleForPromotion())
    ->each(fn($u) => PromoteUser::dispatch($u));

```

The `filter` callback runs per-model as the generator yields, never accumulating a full collection in memory.

---

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

| Scenario | Tool | |---|---| | API pagination, deep pages | `cursorPaginate()` | | Background export, safe under writes | `chunkById()` | | Memory-sensitive pipeline processing | `lazyById()` | | Small datasets, total count needed | `paginate()` |

---

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

- **Never use `chunk()` on tables with concurrent writes** — use `chunkById()` instead.
- Cursor pagination is O(log n) per page; offset pagination degrades linearly.
- `lazyById()` gives you generator-based streaming with Eloquent model hydration and a flat memory profile.
- Always pair cursor/chunk columns with a database index — without one, you trade an offset scan for a full-table seek.
- Composite cursors (`created_at` + `id`) handle non-unique sort columns safely.

 Found this useful?

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

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

  3 questions  

     Q01  When should I use lazyById() over chunkById()?        Use lazyById() when you want to chain Collection pipeline methods (filter, map, each) on the result without loading a full batch into memory at once. Use chunkById() when you need the full batch available as a collection inside the callback, for example to do a bulk insert or dispatch a batch of jobs. 

      Q02  Can cursor pagination work with non-integer primary keys or UUID columns?        Yes. cursorPaginate() encodes the raw column value into the cursor token. UUIDs work fine as long as the column is indexed and the ORDER BY is deterministic. For UUIDs without a natural sort order, pair them with a created_at column to ensure stable ordering. 

      Q03  Does chunkById() work with soft-deleted models?        Yes, but be explicit. If you call chunkById() on a model that uses SoftDeletes, Eloquent adds the WHERE deleted_at IS NULL scope automatically. If you want to include trashed records, call withTrashed() before chunkById(). 

  Continue reading

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

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

 [ ![Laravel Job Batching and Chaining: Coordinating Complex Async Workflows](https://cdn.msaied.com/290/3b69072ea0f13031737fb4104c720593.png) laravel queues async 

### Laravel Job Batching and Chaining: Coordinating Complex Async Workflows

Go beyond simple queued jobs. Learn how to compose Laravel job batches and chains to orchestrate multi-step as...

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

 25 Jun 2026     4 min read  

  Read    

 ](https://msaied.com/articles/laravel-job-batching-and-chaining-coordinating-complex-async-workflows) [ ![FrankenPHP, OPcache JIT, and Preloading: Maximising Laravel Throughput](https://cdn.msaied.com/289/b7db80720c9f35a8631e34515666e691.png) laravel frankenphp opcache 

### FrankenPHP, OPcache JIT, and Preloading: Maximising Laravel Throughput

A practical deep-dive into running Laravel under FrankenPHP with OPcache JIT and preloading enabled — covering...

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

 25 Jun 2026     1 min read  

  Read    

 ](https://msaied.com/articles/frankenphp-opcache-jit-and-preloading-maximising-laravel-throughput) [ ![Laravel Queues: Job Middleware for Idempotency, Rate Limiting, and Graceful Failure](https://cdn.msaied.com/288/46c6bf48663538a47ca37b0655c41674.png) laravel queues job-middleware 

### Laravel Queues: Job Middleware for Idempotency, Rate Limiting, and Graceful Failure

Beyond basic dispatching, Laravel job middleware lets you enforce idempotency, apply fine-grained rate limits,...

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

 25 Jun 2026     1 min read  

  Read    

 ](https://msaied.com/articles/laravel-queues-job-middleware-for-idempotency-rate-limiting-and-graceful-failure) 

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