Eloquent at Scale: Chunk, Lazy, and Cursor Pagination | 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)    Chunked Iteration, Lazy Collections, and Cursor Pagination at Scale in Laravel        On this page       1. [  The Problem With Naive Bulk Processing ](#the-problem-with-naive-bulk-processing)
2. [  chunk() — Reliable But Offset-Dependent ](#codechunkcode-reliable-but-offset-dependent)
3. [  lazy() and lazyById() — Generators Over Eloquent ](#codelazycode-and-codelazybyidcode-generators-over-eloquent)
4. [  Memory Profile ](#memory-profile)
5. [  cursor() — One Row at a Time, One Query Total ](#codecursorcode-one-row-at-a-time-one-query-total)
6. [  Cursor Pagination for APIs ](#cursor-pagination-for-apis)
7. [  Choosing the Right Tool ](#choosing-the-right-tool)
8. [  Key Takeaways ](#key-takeaways)

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

  #laravel   #eloquent   #performance   #database   #scalability  

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

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

       Table of contents

1. [  01   The Problem With Naive Bulk Processing  ](#the-problem-with-naive-bulk-processing)
2. [  02   chunk() — Reliable But Offset-Dependent  ](#codechunkcode-reliable-but-offset-dependent)
3. [  03   lazy() and lazyById() — Generators Over Eloquent  ](#codelazycode-and-codelazybyidcode-generators-over-eloquent)
4. [  04   Memory Profile  ](#memory-profile)
5. [  05   cursor() — One Row at a Time, One Query Total  ](#codecursorcode-one-row-at-a-time-one-query-total)
6. [  06   Cursor Pagination for APIs  ](#cursor-pagination-for-apis)
7. [  07   Choosing the Right Tool  ](#choosing-the-right-tool)
8. [  08   Key Takeaways  ](#key-takeaways)

 The Problem With Naive Bulk Processing
--------------------------------------

Pulling 500,000 rows into a standard Eloquent `get()` call is a fast path to an OOM kill. Laravel ships several mechanisms to handle large datasets, but each has a distinct performance profile and a set of non-obvious failure modes that bite you in production.

---

`chunk()` — Reliable But Offset-Dependent
-----------------------------------------

```php
User::orderBy('id')->chunk(1000, function (Collection $users) {
    foreach ($users as $user) {
        ProcessUser::dispatch($user);
    }
});

```

`chunk()` issues repeated `LIMIT / OFFSET` queries. The fatal flaw: **if you mutate the table inside the callback** — deleting or updating rows that affect the `ORDER BY` column — the offset shifts and you silently skip records.

Use `chunkById()` instead whenever you touch the dataset mid-iteration:

```php
User::orderBy('id')->chunkById(1000, function (Collection $users) {
    $users->each(fn ($u) => $u->update(['synced_at' => now()]));
});

```

`chunkById()` uses a keyset cursor (`WHERE id > ?`) rather than `OFFSET`, making it safe for mutations and dramatically faster on large tables because it avoids a full index scan to reach the offset.

---

`lazy()` and `lazyById()` — Generators Over Eloquent
----------------------------------------------------

Introduced in Laravel 8, `lazy()` wraps `chunk()` in a PHP generator, yielding individual models one at a time:

```php
foreach (User::lazy(500) as $user) {
    // Only 500 rows hydrated at a time, but you iterate one-by-one
    SyncUser::run($user);
}

```

This is ergonomically cleaner than a closure-based `chunk()` and composes naturally with `LazyCollection` methods:

```php
User::lazyById(500)
    ->filter(fn ($u) => $u->needs_sync)
    ->each(fn ($u) => SyncUser::run($u));

```

`lazyById()` carries the same keyset advantage as `chunkById()`. Prefer it over `lazy()` for any table you might write to during iteration.

### Memory Profile

Both `lazy()` variants keep at most one chunk hydrated at a time. For 1 M rows with a 500-row chunk size, you hold ~500 model instances in memory simultaneously — a predictable, bounded footprint regardless of dataset size.

---

`cursor()` — One Row at a Time, One Query Total
-----------------------------------------------

```php
foreach (User::cursor() as $user) {
    // Single unbuffered query; one model hydrated per iteration
    CsvExporter::write($user);
}

```

`cursor()` issues a **single SQL query** and streams results via PDO's unbuffered cursor. Memory usage is minimal — one hydrated model at a time — but the database connection is held open for the entire iteration.

**When `cursor()` is the wrong choice:**

- Long-running jobs (connection timeout risk)
- MySQL with `PDO::MYSQL_ATTR_USE_BUFFERED_QUERY` forced on (some hosting stacks)
- Any code path that opens a second query inside the loop (you'll exhaust the connection)

---

Cursor Pagination for APIs
--------------------------

For paginated API responses, offset-based pagination degrades as page numbers grow. Cursor pagination encodes the last-seen keyset into an opaque token:

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

return UserResource::collection($page);
// Response includes next_cursor / prev_cursor tokens

```

The generated SQL uses `WHERE id > ?` (or a composite keyset for multi-column sorts), giving consistent `O(log n)` performance regardless of how deep into the dataset the client is.

**Gotcha:** `cursorPaginate()` requires a **unique, stable sort column**. Sorting by `created_at` alone on a high-write table produces ties that corrupt the cursor. Always append `id` as a tiebreaker:

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

```

---

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

| Scenario | Recommended API | |---|---| | Background job, no mutations | `lazy()` / `lazyById()` | | Background job, mutates rows | `chunkById()` or `lazyById()` | | CSV/stream export, short-lived | `cursor()` | | REST API pagination | `cursorPaginate()` | | Simple one-off script | `chunk()` (with caution) |

---

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

- `chunk()` with `OFFSET` silently skips rows when you mutate during iteration — always prefer `chunkById()` or `lazyById()` for write-heavy loops.
- `lazy()` / `lazyById()` give you generator ergonomics with bounded memory; they compose with `LazyCollection` pipelines cleanly.
- `cursor()` is fastest for read-only streaming but ties up the DB connection and breaks if you open nested queries.
- `cursorPaginate()` requires a unique sort; append `id` to any non-unique column to prevent cursor drift.
- Profile with `DB::listen()` or Telescope before assuming any approach is fast enough — chunk size and index coverage matter more than the API choice.

 Found this useful?

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

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

  3 questions  

     Q01  What is the difference between `lazy()` and `cursor()` in Laravel Eloquent?        `lazy()` issues multiple chunked queries under the hood and yields models one at a time via a generator, keeping memory bounded to one chunk. `cursor()` issues a single unbuffered SQL query and streams results row by row, using even less memory but holding the database connection open for the full duration of iteration. 

      Q02  Why does `cursorPaginate()` require a unique sort column?        Cursor pagination encodes the last-seen value as a keyset. If two rows share the same sort value, the cursor cannot deterministically point between them, causing rows to be duplicated or skipped across pages. Appending a unique column like `id` as a secondary sort eliminates ties. 

      Q03  When should I avoid `cursor()` in favor of `chunkById()`?        Avoid `cursor()` in long-running jobs where a held database connection may time out, in environments that force buffered PDO queries, or whenever you need to open additional queries inside the iteration loop. `chunkById()` releases and re-acquires the connection per chunk, making it safer for those scenarios. 

  Continue reading

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

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

 [ ![PostgreSQL JSONB in Laravel: Indexing, Querying, and Casting Without the Pain](https://cdn.msaied.com/643/656efe6f0c30b559bcdb27456edcc366.png) laravel postgresql eloquent 

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

JSONB columns unlock flexible schemas inside PostgreSQL, but misused they become slow blobs. Learn how to inde...

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

 8 Sep 2026     3 min read  

  Read    

 ](https://www.msaied.com/articles/postgresql-jsonb-in-laravel-indexing-querying-and-casting-without-the-pain-2) [ ![Filament v5.8.0 Released: Deferred Schema Loading, Session Grouping & More](https://cdn.msaied.com/641/eccc2db7462422ed6a5dd3dbbf991a28.png) Filament Laravel PHP 

### Filament v5.8.0 Released: Deferred Schema Loading, Session Grouping &amp; More

Filament v5.8.0 ships deferred schema loading, persistent table grouping, RichEditor height controls, a reusab...

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

 7 Sep 2026     4 min read  

  Read    

 ](https://www.msaied.com/articles/filament-v580-released-deferred-schema-loading-session-grouping-more) [ ![Filament v4.13.0 Released: Deferred Schema Loading, Session Grouping & More](https://cdn.msaied.com/642/400efa8dc790ea53fa0d52ed69e4f132.png) Filament Laravel PHP 

### Filament v4.13.0 Released: Deferred Schema Loading, Session Grouping &amp; More

Filament v4.13.0 ships with deferred schema loading, persistent table grouping in the session, RichEditor min/...

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

 7 Sep 2026     4 min read  

  Read    

 ](https://www.msaied.com/articles/filament-v4130-released-deferred-schema-loading-session-grouping-more) 

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