Cursor Pagination, Chunked Iteration, and Lazy Collections at Scale in Laravel
#laravel #eloquent #performance #pagination #collections

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

4 min read Mohamed Said Mohamed Said

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.

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

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

The generated SQL looks like:

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.
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.

// 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:

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

The generated SQL per batch:

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().

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:

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?

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