Laravel Tutorials

Group Adjacent Collection Items in Laravel with chunkBy()

Published
Group Adjacent Collection Items in Laravel with chunkBy() image

Laravel 13.30 introduces the chunkBy() method, which is a convenient shorthand for the most common chunkWhile() use case:

$products->chunkWhile(fn ($value, $key, $chunk) => $value->parent == $chunk->last()->parent);

Which, you can now write as simply:

$products->chunkBy('parent');

Before: chunkWhile() and a Comparison

As shown above, before Laravel 13.30, you could use chunkWhile() to handle this. It takes a callback receiving the current value, its key, and the chunk being built, and it starts a new chunk whenever the callback returns false:

$lineItems->chunkWhile(
fn ($value, $key, $chunk) => $value->order_id == $chunk->last()->order_id
);

The interesting part of the line is one word (order_id) buried in a comparison between a value and $chunk->last(). chunkBy() takes a key or a callback and builds the comparison for you:

$lineItems->chunkBy('order_id');
 
$lineItems->chunkBy(fn ($item) => $item->order_id);

The key resolves through data_get(), so dot notation reaches into nested arrays and objects:

$users->chunkBy('address.city');

Adjacent, Not Grouped

The distinction from groupBy() is the thing to internalize, because the methods return the same shape and disagree only about your data's order:

collect([1, 1, 2, 2, 1, 1])->chunkBy(fn ($v) => $v);
// [[1, 1], [2, 2], [1, 1]]
 
collect([1, 1, 2, 2, 1, 1])->groupBy(fn ($v) => $v);
// [1 => [1, 1, 1, 1], 2 => [2, 2]]

The chunkBy() method produces three chunks because the two runs of 1 are not next to each other. Nothing about that is a bug to work around; it is the property that makes the method cheap. If non-adjacent items with the same value need to end up together, the data is not sorted the way chunkBy() needs it, and either sort it first or use groupBy().

Keys are preserved inside each chunk:

collect(['a' => 1, 'b' => 1, 'c' => 2])->chunkBy(fn ($v) => $v);
// [['a' => 1, 'b' => 1], ['c' => 2]]

Call values() on a chunk if you want a list.

Streaming a Sorted Query

The chunkBy() method is added to normal collections as well as LazyCollection. Since chunkBy() inherits chunkWhile()'s laziness, on a lazy collection it yields each chunk as soon as the value changes and never holds more than the current chunk in memory.

Consider exporting a per-order CSV for a table with a few million line items. With groupBy(), every row is stored in memory before the first file is written. With a cursor and chunkBy(), the highest memory point will be during the largest single order:

use App\Models\LineItem;
use Illuminate\Support\Facades\Storage;
 
LineItem::query()
->orderBy('order_id')
->orderBy('id')
->cursor()
->chunkBy('order_id')
->each(function ($items) {
$orderId = $items->first()->order_id;
 
Storage::disk('exports')->put(
"orders/{$orderId}.csv",
$items->map(fn ($item) => implode(',', [
$item->sku,
$item->quantity,
$item->unit_price,
]))->implode(PHP_EOL)
);
});

The orderBy('order_id') is not decoration. It is the contract chunkBy() runs on: the database does the sorting, in an index, and PHP does the splitting, one row at a time.

The same shape works over a log file:

use Illuminate\Support\LazyCollection;
 
LazyCollection::make(function () {
$handle = fopen(storage_path('logs/laravel.log'), 'r');
 
while (($line = fgets($handle)) !== false) {
yield $line;
}
})
->chunkBy(fn ($line) => str_contains($line, 'ERROR') ? 'error' : 'other')
->each(function ($block) {
// Each block is a consecutive run of error or non-error lines.
});

Or over a paginated API, or a generator reading a CSV. Anywhere the source is ordered and larger than memory, chunkBy() turns "group by" into a streaming operation.

Two Things Worth Knowing

The comparison is loose. The implementation compares the resolved values with ==, not ===:

collect(['1', 1, 1.0])->chunkBy(fn ($v) => $v);
// one chunk

For a database column that yields a consistent type this isn't an issue. For mixed input it can merge chunks you expected to be separate. Return a normalized value from the callback if needed:

$rows->chunkBy(fn ($row) => (string) $row['code']);

Two objects compare loosely as equal when they are the same class with equal properties, which is usually what you want when chunking by a value object.

The resolver runs twice per item. Each boundary check resolves the current item and re-resolves the last item of the chunk. If the callback is expensive, say a date parse or a hash, precompute the value first:

$entries
->map(fn ($entry) => [$entry, Carbon::parse($entry->logged_at)->toDateString()])
->chunkBy(fn ($pair) => $pair[1]);

For a plain key or property lookup this is irrelevant.

Edge Cases

An empty collection returns an empty collection. A single item returns one chunk containing it. Both eager and lazy collections return the same class they were called on, so chunkBy() on a LazyCollection gives you a LazyCollection of LazyCollection instances.

Contributed by @JosephSilber in #61357.

Paul Redmond photo

Staff writer at Laravel News. Full stack web developer and author.

Sponsored

serpapi logo
SerpApi

The Web Search API for Your LLM and AI Applications

Visit SerpApi

The latest

View all →
Laravel queue:work Now Prints Why the Worker Stopped image

Laravel queue:work Now Prints Why the Worker Stopped

Read article
Sidecar Brings Statamic's Control Panel to Your Existing Markdown Sites image

Sidecar Brings Statamic's Control Panel to Your Existing Markdown Sites

Read article
Collections chunkBy() and Storage Path Hardening in Laravel 13.30 image

Collections chunkBy() and Storage Path Hardening in Laravel 13.30

Read article
Forte: Parse and Rewrite Laravel Blade Templates image

Forte: Parse and Rewrite Laravel Blade Templates

Read article
Compoships: Eloquent Relationships on Multiple Columns image

Compoships: Eloquent Relationships on Multiple Columns

Read article
MKSine: A Filament CMS with Plugins, Themes, and Blocks image

MKSine: A Filament CMS with Plugins, Themes, and Blocks

Read article