Коллекции 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(); // подсчёт по группам в одну строку
И три вывода:
groupBy()заменяет циклы с$result[$key][] = $item— ключ ведёт к коллекции элементов.keyBy()заменяет вложенные поиски черезfirstWhere()— ключ ведёт к одному элементу, доступ мгновенный.- После
groupBy()значения — полноценные коллекции:map,count,pluckработают прямо поверх групп.