Tech Verse Logo
Enable dark mode
Laravel Chunk vs Cursor: Fixing the Mutation Bug

Laravel Chunk vs Cursor: Fixing the Mutation Bug

Md. Mostafijur RahmanMMd. Mostafijur Rahman

Md. Mostafijur Rahman

4 min read

The Standard Chunk Trap

If you're processing 100,000 database records in Laravel 12, loading every model with User::all() will push PHP's memory consumption past 180MB. In environments with a 128MB memory_limit, the process dies with an exhausted memory error. The standard remedy for years has been chunk(). But chunk() carries a hidden defect when you modify the query's filtered columns inside the callback loop.

Under the hood, chunk($count, $callback) executes paginated SQL queries using standard LIMIT and OFFSET clauses. On iteration zero, Laravel runs SELECT * FROM users LIMIT 1000 OFFSET 0. On iteration one, it runs SELECT * FROM users LIMIT 1000 OFFSET 1000.

Here's where developers shoot themselves in the foot:

// DANGER: This skips half your dataset! User::where('processed', false)->chunk(1000, function ($users) {     foreach ($users as $user) {         $user->update(['processed' => true]);     } });

When batch zero completes, 1,000 records have processed = true. On iteration one, Laravel requests LIMIT 1000 OFFSET 1000 against WHERE processed = false. Because the first 1,000 updated rows no longer match the WHERE condition, offset 1,000 actually skips rows 1,001 through 2,000 and grabs rows 2,001 through 3,000 instead. Exactly 50% of your records are silently skipped without throwing a single exception.

chunkById: Fixing Row Offsets During Updates

To safely update records in chunks without skipping rows, use chunkById(). Instead of relying on SQL OFFSET, chunkById() orders records by primary key and appends a WHERE id > last_processed_id constraint on every subsequent batch query.

First query: SELECT * FROM users WHERE processed = 0 ORDER BY id ASC LIMIT 1000.

Second query: SELECT * FROM users WHERE processed = 0 AND id > 1000 ORDER BY id ASC LIMIT 1000.

// Safe mutation: chunkById tracks the primary key User::where('processed', false)->chunkById(1000, function ($users) {     foreach ($users as $user) {         $user->update(['processed' => true]);     } });

Because the offset is determined by the last seen ID rather than the total row count matching the query, updating the state of processed records won't shift the pointer. Memory consumption stays flat at around 12.3MB for 100,000 records on PHP 8.3 because Laravel releases the Eloquent collection at the end of each callback block.

There is a catch: chunkById() requires an auto-incrementing integer or sequential primary key. If your table uses UUIDs or ULIDs without a deterministic sequential column, chunkById() won't work out of the box unless you explicitly pass your continuous sort column as the alias parameter: chunkById(1000, $callback, 'created_at', 'id').

cursor(): True Row-by-Row Streaming with PDO

While chunk() and chunkById() load records in array batches, cursor() uses a PHP generator (via yield) to iterate over your database query one single model at a time. It executes a single SQL query and streams results directly through PDO.

// Minimal memory allocation: ~2.4MB peak memory foreach (User::where('active', true)->cursor() as $user) {     // Process single model     dispatch(new ProcessUserData($user)); }

When running benchmarks on PHP 8.3 and Laravel 12 with a database of 100,000 user records, the memory footprint differences are clear:

  • User::all(): 184MB peak memory (Process aborted at 128MB limit).
  • chunk(1000): 12.3MB peak memory (Runs 100 individual SQL queries).
  • chunkById(1000): 12.3MB peak memory (Safe against row modification).
  • cursor(): 2.4MB peak memory (Runs 1 SQL query, streams models).

cursor() gives you the lowest memory ceiling possible because Laravel instantiates and garbage-collects each Eloquent model individually as you advance through the loop.

The Gotchas with cursor()

cursor() isn't a silver bullet. Because it keeps a single database connection cursor open during the entire iteration loop, executing nested queries inside the loop can trigger issues depending on your database driver. MySQL unbuffered queries require the connection to remain dedicated solely to fetching rows until the cursor finishes. If you execute another query inside the loop on the exact same MySQL connection, PDO can raise an Unbuffered queries active error or quietly fetch entire result sets into memory anyway, destroying your memory gains.

If you need to update rows or run side-effect queries inside a cursor() loop, dispatch background jobs to Redis or push updates through direct raw SQL statements on a secondary database connection.

lazy(): Generator-Powered Collections

Laravel introduced lazy() to bridge the gap between Eloquent collections and low-memory iteration. Under the hood, lazy() uses chunkById() to fetch chunks of records while returning a LazyCollection. This allows you to chain collection methods like map(), filter(), and take() without loading all underlying models into memory at once.

// LazyCollection processing 100k records in 1000-row chunks User::lazy(1000)     ->filter(fn ($user) => $user->hasVerifiedEmail())     ->each(function ($user) {         $user->notify(new MonthlySummaryNotification());     });

Unlike standard Collection methods, LazyCollection pipeline operations only evaluate item-by-item when triggered by a terminal operation like each(), first(), or collect(). If you call User::lazy()->take(5)->get(), Laravel will only query the first batch of records and halt database execution immediately after reaching five matches.

Which Approach Should You Choose?

Here is my practical rule of thumb when building background batch workers or console commands in Laravel 12:

  1. Read-only data processing: Use cursor(). It offers the lowest peak memory overhead (under 3MB for massive tables) and runs a single efficient database query.
  2. Batch operations with row mutations: Use chunkById(). Never use chunk() when updating columns present in your query's WHERE clause. chunkById() prevents silent row skips.
  3. Pipeline data transformations: Use lazy(). It gives you fluent collection method chaining while keeping memory capped to your chosen chunk size.

Avoid chunk() for any mutation logic. The standard OFFSET pagination behavior is a trap waiting to break production jobs as soon as your dataset scales beyond a single chunk.

Md. Mostafijur RahmanMMd. Mostafijur Rahman

WRITTEN BY

Md. Mostafijur Rahman

    Latest Posts

    View All

    Laravel Horizon Monitoring: Setup and Metrics

    Laravel Horizon Monitoring: Setup and Metrics

    Laravel Chunk vs Cursor: Fixing the Mutation Bug

    Laravel Chunk vs Cursor: Fixing the Mutation Bug

    Real-Time Laravel Reverb and Next.js WebSockets

    Real-Time Laravel Reverb and Next.js WebSockets

    Laravel Events: When to Use Them and When They Obscure Flow

    Laravel Events: When to Use Them and When They Obscure Flow

    Laravel Database Index Tuning with Postgres EXPLAIN

    Laravel Database Index Tuning with Postgres EXPLAIN

    Preventing Laravel Cache Stampedes with Atomic Locks

    Preventing Laravel Cache Stampedes with Atomic Locks

    Fast Laravel Database Testing Without In-Memory SQLite

    Fast Laravel Database Testing Without In-Memory SQLite

    Testing Laravel APIs with Pest: A Practical Guide

    Testing Laravel APIs with Pest: A Practical Guide

    Mocking HTTP Clients in Laravel 12 with Http::fake

    Mocking HTTP Clients in Laravel 12 with Http::fake

    Detecting and Fixing N+1 Queries in Laravel 12

    Detecting and Fixing N+1 Queries in Laravel 12