Laravel 13.30: новый метод chunkBy()

В 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, которому передаются текущее значение, его ключ и формируемый в данный момент фрагмент. Новый фрагмент начинается, когда callback возвращает false:

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

Самое интересное здесь это order_id, спрятанное внутри сравнения текущего значения с $chunk->last(). chunkBy() берёт ключ или callback и самостоятельно выполняет это сравнение:

$lineItems->chunkBy('order_id');

Либо можно передать callback:

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

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

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

Соседние элементы, а не все элементы с одинаковым значением

Важно не путать chunkBy() с groupBy(). Оба метода возвращают похожую структуру, но принципиально отличаются тем, как учитывают порядок элементов.

Например:

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

// [[1, 1], [2, 2], [1, 1]]

В этом случае chunkBy() создаёт три группы. Две последовательности с 1 находятся в разных местах коллекции, поэтому они остаются отдельными фрагментами. groupBy() работает иначе:

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

// [1 => [1, 1, 1, 1], 2 => [2, 2]]

Здесь все элементы с одинаковым значением объединяются независимо от их положения в исходной коллекции.

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

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

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

// [['a' => 1, 'b' => 1], ['c' => 2]]

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

Потоковая обработка отсортированного запроса

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

Представим экспорт нескольких миллионов записей line_items в отдельные CSV-файлы для каждого заказа.

При использовании groupBy() все строки пришлось бы держать в памяти ещё до создания первого файла. В связке с курсором и 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) {
        // Каждый блок содержит последовательную группу строк с ошибками
        // или строк без ошибок.
    });

То же самое подходит для постраничного API или генератора, который читает CSV. Если источник данных упорядочен и его размер превышает доступную память, chunkBy() позволяет превратить обычную группировку в потоковую операцию.

Два важных нюанса

Сравнение выполняется нестрого.

Реализация сравнивает полученные значения через ==, а не ===:

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

// один фрагмент

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

Если необходимо, в callback можно вернуть нормализованное значение:

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

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

Resolver вызывается дважды для каждого элемента.

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

Если callback выполняет дорогую операцию, например парсит дату или вычисляет хеш, значение лучше предварительно вычислить:

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

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

Похожие статьи

Рекомендательные технологии Подробнее

Пауза всех очередей Laravel во время деплоя

Laravel 13.25 добавляет возможность поставить на паузу все очереди приложения одной командой. Разбираем команды queue:pause --all и queue:resume --all, работу глобальной паузы, взаимодействие с индивидуальными паузами и новые события.

Разработка и инструменты • 8 месяцев назад

SSH проброс портов: руководство с примерами

Подробное руководство по пробросу портов через SSH. Разбираем локальный, удалённый и динамический режимы с примерами подключения к базе данных, веб-серверу и настройкой SOCKS-прокси.

Разработка и инструменты • 5 месяцев назад

Google Sheets как база данных в Laravel

Пакет grosv/eloquent-sheets позволяет использовать Google Sheets как источник данных для Eloquent-моделей Laravel. Решение подходит для MVP, внутренних сервисов и небольших проектов с совместным редактированием данных.