Eloquent at Scale: Chunk, Lazy, and Cursor Pagination | 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. Chunked Iteration, Lazy Collections, and Cursor Pagination at Scale in Laravel

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

 Processing millions of Eloquent rows without exhausting memory requires the right tool for the job. Learn when to reach for chunk(), lazy(), lazyById(), and cursor pagination — and the subtle traps each one hides.

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

ShareCopy linkCopied

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

  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)

 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.

- [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)
- [database](https://www.msaied.com/public/articles?search=database)
- [scalability](https://www.msaied.com/public/articles?search=scalability)

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

  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.

   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.

   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.

   ![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 articlePostgreSQL JSONB in Laravel: Indexing, Querying, and Casting Without the Pain](https://www.msaied.com/public/articles/postgresql-jsonb-in-laravel-indexing-querying-and-casting-without-the-pain-2) [Next articleRead/Write Splitting and Sticky Reads in Laravel: A Production Guide](https://www.msaied.com/public/articles/readwrite-splitting-and-sticky-reads-in-laravel-a-production-guide)  

   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)

 ###  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/743/8998fac3a41451ab3fe1588194e17a43.png) Filament · 3 min read### Securing Filament Plugins with Plumb: Automated Security Scoring for PHP Packages

5 Oct 2026 ](https://www.msaied.com/public/articles/securing-filament-plugins-with-plumb-automated-security-scoring-for-php-packages) [ ![](https://cdn.msaied.com/742/2d02018669cdeedccb5de2efb898f0ee.png) Filament · 3 min read### Filament v3.3.56 Released: File Hash Names and Livewire Upload Fix

5 Oct 2026 ](https://www.msaied.com/public/articles/filament-v3356-released-file-hash-names-and-livewire-upload-fix) [ ![](https://cdn.msaied.com/741/5b55c123ad08e4d34e1f4b99ad6a428b.png)  · 3 min read### Filament v4 Schema-Based Forms, Infolists, and the Unified Schema API

5 Oct 2026 ](https://www.msaied.com/public/articles/filament-v4-schema-based-forms-infolists-and-the-unified-schema-api-5) 

  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)
