Коллекции Laravel: groupBy() и keyBy() — превращаем плоский список в структуру одной строкой

Зачем это нужно

Знакомая картина: из базы приходит плоский список, а на выходе нужна структура — «сообщения, сгруппированные по чатам», «заказы, сгруппированные по статусам», «категории, проиндексированные по ID». Классическая реализация:

$byChat = [];
foreach ($messages as $message) {
    $byChat[$message->chat_id][] = $message;
}

Работает. Но четыре строки, промежуточная переменная, плюс этот паттерн копируется по всему проекту с вариациями: где-то [] =, где-то перезапись, где-то счётчик. groupBy() и keyBy() — это именно эти два паттерна, упакованные в методы.


groupBy() — сгруппировать по признаку

groupBy() принимает замыкание или имя поля и возвращает коллекцию, где ключи — значения признака, а значения — коллекции элементов с этим признаком.

Возьмём сообщения чата — прямая задача из проекта с мессенджером:

$messages = ChatMessage::whereIn('chat_id', $chatIds)->get();

$byChat = $messages->groupBy('chat_id');

Результат:

Illuminate\Support\Collection {
  5 => Collection { [сообщение, сообщение, ...] },
  12 => Collection { [сообщение] },
  ...
}

Теперь «список непрочитанных по чатам» для бейджиков — три строки:

$unreadByChat = $messages
    ->filter(fn ($m) => !$m->is_read)
    ->groupBy('chat_id')
    ->map(fn ($messages) => $messages->count());
// [5 => 3, 12 => 1, ...] — сколько непрочитанных в каждом чате

Обратите внимание на map() поверх групп: после groupBy() значения — тоже коллекции, и к ним применим весь привычный API: count(), pluck(), map() — что угодно.

groupBy по вычисляемому признаку

Вместо имени поля можно передать замыкание — так группируют не по колонке, а по логике:

$ordersByDay = $orders->groupBy(fn ($order) => $order->created_at->format('Y-m-d'));

Или многомерно — по нескольким признакам сразу:

$byStatusAndDay = $orders->groupBy([fn ($o) => $o->status, fn ($o) => $o->created_at->format('Y-m-d')]);

Получится вложенная структура «статус → день → заказы». Пригодится для отчётов и дашбордов.

Подводный камень: null-ключи

Если у части элементов группирующее поле пустое, они соберутся под ключом null:

$byCategory = $products->groupBy('category_id');
// ... 3 => [...], null => [товар без категории]

Это не баг — это фича, но она может укусить при переборе или сериализации. Если таких элементов быть не должно — отфильтруйте заранее: ->filter(fn ($p) => $p->category_id !== null)->groupBy(...).


keyBy() — проиндексировать по ключу

keyBy() делает другое: превращает коллекцию в «словарь», где каждый элемент доступен по ключу. Никаких групп — один элемент на один ключ (последний перезаписывает предыдущие).

Сравните:

// groupBy: ключ -> коллекция элементов
$byStatus = $orders->groupBy('status');
$byStatus['paid']->count();     // все оплаченные

// keyBy: ключ -> один элемент
$byId = $orders->keyBy('id');
$byId[42]->total;               // конкретный заказ — мгновенно, без поиска

Типичный сценарий из практики: у вас есть список чатов и отдельный список последних сообщений. Нужно быстро найти сообщение для каждого чата по chat_id. Без keyBy() — вложенный цикл или firstWhere() в цикле, что даёт квадратичную сложность:

// ПЛОХО: перебор на каждой итерации — O(n*m)
foreach ($chats as $chat) {
    $lastMessage = $messages->firstWhere('chat_id', $chat->id);
}

С keyBy() — линейный проход и мгновенный доступ:

// ХОРОШО: индексируем один раз — O(n)
$messagesByChat = $messages->keyBy('chat_id');

foreach ($chats as $chat) {
    $lastMessage = $messagesByChat[$chat->id] ?? null;
}

На десятке чатов разницы не видно, на тысяче — заметно. Это тот редкий случай, когда коллекционный метод даёт не только читаемость, но и реальную производительность.

keyBy по вычисляемому ключу

Как и groupBy(), умеет замыкания:

$usersByEmail = $users->keyBy(fn ($u) => strtolower($u->email));
// нормализуем ключ — теперь поиск по email регистронезависим

Связка: та самая «карта непрочитанных»

Соберём всё вместе — паттерн, который встречается в каждом проекте с чатами, уведомлениями или списками задач:

$data = Chat::with('messages')->get();

$unreadMap = $data
    ->flatMap(fn ($chat) => $chat->messages)
    ->filter(fn ($m) => !$m->is_read)
    ->groupBy('chat_id')
    ->map(fn ($messages) => $messages->count());

Результат — плоский словарь chat_id => количество непрочитанных. Отдаём его в Blade или в JSON-ответ мобильному клиенту — и бейджики рисуются без единого дополнительного запроса к базе.

Или вариант с keyBy() для «последнее сообщение чата»:

$lastByChat = $chat->messages->sortByDesc('created_at')->keyBy('chat_id');

sortByDesc() ставит самые свежие вперёд, а keyBy() оставляет первый элемент для каждого ключа — то есть последнее сообщение каждого чата. Два метода вместо вложенного цикла с проверками.


Мини-шпаргалка

$collection->groupBy('поле');                          // сгруппировать по колонке
$collection->groupBy(fn ($item) => ...);              // сгруппировать по вычисляемому признаку
$collection->groupBy(['a', 'b']);                     // вложенная группировка
$collection->keyBy('поле');                           // словарь: ключ -> один элемент
$collection->keyBy(fn ($item) => ...);                // словарь с вычисляемым ключом
$collection->groupBy('x')->map->count();              // подсчёт по группам в одну строку

И три вывода:

  1. groupBy() заменяет циклы с $result[$key][] = $item — ключ ведёт к коллекции элементов.
  2. keyBy() заменяет вложенные поиски через firstWhere() — ключ ведёт к одному элементу, доступ мгновенный.
  3. После groupBy() значения — полноценные коллекции: map, count, pluck работают прямо поверх групп.