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

    Local LLM Development with Ollama and Hardware Limits

    Local LLM Development with Ollama and Hardware Limits

    LLM API Pricing Comparison for Side Projects

    LLM API Pricing Comparison for Side Projects

    Building Semantic Search with Laravel 12 and Next.js 16

    Building Semantic Search with Laravel 12 and Next.js 16

    Fine-Tuning vs RAG vs Prompts: Choosing the Right AI Tool

    Fine-Tuning vs RAG vs Prompts: Choosing the Right AI Tool

    Wiring LLMs with Tool Function Calling

    Wiring LLMs with Tool Function Calling

    LLM API Rate Limit Caching and Quota Guarding

    LLM API Rate Limit Caching and Quota Guarding

    Streaming LLM Responses with Laravel 12 and Next.js 16

    Streaming LLM Responses with Laravel 12 and Next.js 16

    Pgvector Similarity Search in Production Postgres

    Pgvector Similarity Search in Production Postgres

    React Drag and Drop with dnd-kit and Laravel

    React Drag and Drop with dnd-kit and Laravel

    Building a Production RAG Pipeline in PHP 8.3

    Building a Production RAG Pipeline in PHP 8.3