Collection - це обгортка над звичайним PHP-масивом. Коли Post::where(...)->get() повертає Eloquent\Collection, дані вже прочитані з бази, передані по мережі й перетворені на обʼєкти моделей; усе, що ви робите далі, відбувається над цим масивом. Звідси ростуть і зручність, і обмеження. Ланцюжок ->filter()->map()->groupBy() читається зверху вниз, кожен метод повертає новий екземпляр і не псує попередній, а замість трьох тимчасових змінних і вкладених foreach виходить один вираз. Плюс Enumerable приносить майже сотню методів, які інакше довелося б писати руками: pluck(), keyBy(), partition(), flatMap(), sole(), sliding(), higher-order-проксі на кшталт $orders->sum->total чи $users->each->notify().
Ціна теж механічна. Кожен метод ланцюжка робить повний прохід по набору й будує новий масив, тож map()->filter()->map() дає три обходи й три масиви замість одного розумного проходу. На 50 елементах це непомітно, на 500 тисячах рахунок іде на сотні мегабайтів. Є й дрібніші сюрпризи: filter() і reject() зберігають початкові ключі, і колекція [1 => 'b', 3 => 'd'] перетворюється в JSON на обʼєкт, а не на масив, тому перед toJson() майже завжди потрібен values(). where() на колекції порівнює через ==, строгий варіант називається whereStrict(). groupBy() приводить ключі до рядків, тому група null стає порожнім рядком.
Найдорожча помилка живе на межі з базою: у тому, звідки колекція взяла дані. Order::all()->where('status', 'paid')->sum('total') виглядає майже як SQL, але база тут отримує select * from orders без жодної умови: вона читає всю таблицю, драйвер передає її в PHP, Eloquent створює обʼєкт на кожен рядок з атрибутами, оригіналами й кастами, і лише потім починається фільтрація. Індекс на status не використовується взагалі, бо в запиті немає where. Той самий результат Order::where('status', 'paid')->sum('total') дає одним select sum(total), який повертає один рядок. Межа проходить тут: фільтрація, сортування, агрегація й limit належать базі, бо вона має для цього індекси й уміє не читати зайвого; колекція надає форму вже отриманим даним. Побічний наслідок фільтра в PHP, про який часто забувають, - зламана пагінація: paginate() рахує total по базі, а на сторінку потрапляє прорідений набір, і замість 20 записів користувач бачить 13.
LazyCollection (доступна з Laravel 6) розвʼязує ту частину задачі, де рядки справді потрібні всі, але в памʼять вони не влазять. Усередині там генератор: Order::cursor()->filter(...)->each(...) тримає в памʼяті один елемент за раз, бо filter() не будує новий масив, а лише вирішує, пропускати yield далі чи ні. Джерел для лінивої колекції три. cursor() виконує один запит і гідрує моделі по одній, lazy(1000) ходить пачками через offset/limit, lazyById(1000) замість офсета додає умову id > ?, тому глибокі пачки не дорожчають, а видалення чи оновлення рядків під час обходу не зсуває вибірку. Тут же ховається найпоширеніший міф про cursor(): PDO за замовчуванням буферизує весь результат запиту в памʼяті PHP і на MySQL, і на PostgreSQL, тому економія стосується лише обʼєктів-моделей, а не сирих даних. На дійсно великих таблицях працює lazyById(), а не cursor(). Другий підводний камінь - методи, яким потрібен весь набір: sort(), sortBy(), groupBy(), reverse() формально повертають LazyCollection, а всередині викликають collect() і матеріалізують усе, тобто лінивість там закінчується.
Висновок «колекції повільні» з цього не випливає. Для тих кількох десятків рядків, що реально показуються на сторінці, map() і groupBy() коштують мікросекунди, а читабельність окупається одразу; передчасно переписувати ланцюжок на foreach заради економії одного проходу немає сенсу. Дивитися треба на два питання: скільки рядків приїхало з бази й скільки з них справді потрібно. Якщо в PHP заходить 200 тисяч моделей заради 12 чисел у звіті, проблема в запиті, і жодна оптимізація ланцюжка її не виправить. Якщо рядків небагато, але вони мають бути моделями лише для id і total, допоможе ->toBase() чи DB::table() з select потрібних колонок. А коли обходити доводиться все, це робота для lazyById() або chunkById() у консольній команді чи черзі, а не для контролера з all().
use Illuminate\Support\Collection;
// Погано: select * from orders без where, 200 тис. моделей у памʼяті,
// фільтр, групування й сума рахуються в PHP.
$revenue = Order::all()
->where('status', 'paid') // where() колекції, а не SQL
->groupBy(fn (Order $o) => $o->created_at->format('Y-m'))
->map(fn (Collection $month) => $month->sum('total'));
// Добре: фільтрує, групує й сумує база, у PHP приїжджає 24 рядки.
$revenue = Order::query()
->where('status', 'paid')
->selectRaw("DATE_FORMAT(created_at, '%Y-%m') as period, SUM(total) as revenue")
->groupBy('period')
->orderBy('period')
->pluck('revenue', 'period'); // ['2026-01' => 91240.00, ...]
// Коли рядки таки потрібні всі (експорт у CSV) - генератор замість масиву.
// lazyById() ходить пачками по id, тому offset не росте,
// а памʼять не залежить від кількості замовлень.
Order::query()
->where('status', 'paid')
->select(['id', 'total']) // менше колонок - легші моделі
->lazyById(1000)
->each(function (Order $order) use ($handle): void {
fputcsv($handle, [$order->id, $order->total]);
});
// Пастка ключів: filter() зберігає початкові індекси.
$even = collect([1, 2, 3, 4])->filter(fn (int $n) => $n % 2 === 0);
$even->toJson(); // {"1":2,"3":4} - обʼєкт, а не масив
$even->values()->toJson(); // [2,4]
Проведіть межу вголос: база фільтрує, рахує й сортує, колекція форматує те, що вже приїхало. Далі назвіть симптом, за яким це видно в коді: `all()`, `get()` без `where`, `count()` чи `sum()` після завантаження. І додайте, що для великих обсягів існує `LazyCollection` з генераторами, де ланцюжок обробляє один елемент за раз замість того, щоб тримати весь набір.