Laravel 13.30 представляет метод  chunkBy() , который является удобным сокращением для наиболее частых сценариев использования  chunkWhile() :

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

Теперь это можно записать гораздо проще:

$products->chunkBy('parent');
 

#До:  chunkWhile()  и сравнение

Как показано выше, до выхода Laravel 13.30 для решения этой задачи можно было использовать  chunkWhile() . Метод принимает замыкание (callback), получающее текущее значение, его ключ и формируемый чанк (chunk), и начинает новый чанк каждый раз, когда замыкание возвращает false:

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

Самая интересная часть этой строки — одно слово ( order_id ), скрытое среди сравнений значения с  $chunk->last() . Метод  chunkBy()  принимает ключ или замыкание и формирует сравнение за вас:

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

Ключ обрабатывается с помощью функции  data_get() , поэтому точечная нотация (dot notation) позволяет обращаться к вложенным массивам и объектам:

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

#По соседству, а не сгруппировано

Важно усвоить главное отличие от  groupBy() : эти методы возвращают данные одинаковой структуры и различаются лишь требованиями к порядку элементов в вашей коллекции:

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

Метод  chunkBy()  создает три чанка, потому что две группы элементов  1  не находятся рядом друг с другом. В этом нет никакой ошибки, которую нужно исправлять — именно это свойство делает метод эффективным. Если элементы с одинаковым значением, находящиеся в разных частях коллекции, должны оказаться вместе, значит, данные не отсортированы так, как требуется для  chunkBy() . В таком случае предварительно отсортируйте их или используйте  groupBy() .

Ключи внутри каждого чанка сохраняются:

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

Если вам нужен обычный список (массив с числовыми индексами), вызовите  values()  для чанка.

#Стриминг отсортированного запроса

Метод  chunkBy()  добавлен как в обычные коллекции, так и в  LazyCollection . Поскольку  chunkBy()  наследует ленивость (laziness) от  chunkWhile() , при работе с ленивой коллекцией он возвращает каждый чанк сразу же при изменении значения, никогда не удерживая в памяти больше данных, чем содержится в текущем чанке.

Представьте экспортеры CSV-файлов по каждому заказу для таблицы с несколькими миллионами позиций. При использовании  groupBy()  каждая строка сохраняется в памяти до того, как будет записан первый файл. При использовании курсора (cursor) и  chunkBy()  пиковое потребление памяти будет ограничено размером самого большого отдельного заказа:

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)
        );
    });
 

Вызов  orderBy('order_id')  здесь не для красоты. Это условие, на котором работает  chunkBy() : база данных выполняет сортировку по индексу, а PHP производит разделение по одной строке за раз.

Тот же подход работает и при обработке лог-файлов:

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.
    });
 

Или при работе с постраничным (paginated) API, либо с генератором, читающим CSV. Везде, где источник данных упорядочен и превышает объем доступной памяти,  chunkBy()  превращает группировку в потоковую операцию.

#Две вещи, которые стоит знать

 Сравнение является нестрогим. Реализация сравнивает полученные значения с помощью оператора  == , а не  === :

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

Для колонок базы данных, возвращающих данные стабильного типа, это не проблема. Но при смешанных входных данных это может привести к объединению чанков, которые вы рассчитывали получить раздельно. При необходимости возвращайте из замыкания нормализованное значение:

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

Два объекта считаются равными при нестрогом сравнении, если они принадлежат к одному классу и имеют равные свойства, что обычно и требуется при разбивке на чанки по объекту-значению (value object).

 Резолвер запускается дважды для каждого элемента. Каждая проверка границы определяет текущий элемент и заново определяет последний элемент чанка. Если колбэк требует больших вычислительных затрат (например, парсинг даты или хеширование), предварительно вычислите значение:

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

Для простого поиска по ключу или свойству это не имеет значения.

#Краевые случаи

Пустая коллекция возвращает пустую коллекцию. Один элемент возвращает один чанк, содержащий его. Как жадные, так и ленивые коллекции возвращают тот же класс, на котором они были вызваны, поэтому вызов  chunkBy()  для  LazyCollection  возвращает  LazyCollection  экземпляров  LazyCollection .

Автор кода: @JosephSilber в #61357.