<? phpukraine СПІВБЕСІДИ
Пошук по платформі
LARAVEL · JUNIOR

Що дають Laravel Collections і де вони шкодять продуктивності?

`Collection` - це обгортка над масивом, який уже цілком у памʼяті, тож кожен `map()`/`filter()`/`groupBy()` робить повний прохід і створює новий масив, а `Model::all()->where(...)` спершу читає всю таблицю. Фільтрувати, рахувати й групувати має база; `LazyCollection` через `cursor()`, `lazy()` і `lazyById()` потрібна тоді, коли рядків більше, ніж влазить у памʼять.

Чим `collect($rows)->filter()` кращий за `array_filter()`, крім вигляду?
Сторінка звіту падає з `Allowed memory size exhausted` на 200 тисячах замовлень, хоча в шаблоні виводиться 12 чисел. Де шукати?
Відфільтрували колекцію і віддали в JSON, а замість масиву прийшов обʼєкт з ключами `0`, `3`, `7`. Чому?
`Order::all()->where('status', 'paid')->count()` і `Order::where('status', 'paid')->count()` дадуть одне число. Який SQL піде в базу в кожному випадку?
Collections LazyCollection памʼять Eloquent cursor

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]
Що `Collection` не має жодного стосунку до SQL: до моменту виклику `filter()` дані вже прочитані, перекладені в PHP-обʼєкти й лежать у памʼяті.
Що `Model::all()->where(...)` і `Model::where(...)->get()` різняться не стилем, а запитом: у першому випадку йде `select *` без `where`, у другому фільтрує база й може використати індекс.
Що кожен метод ланцюжка - це окремий прохід по всьому набору й новий масив; на 50 елементах це нічого не варте, на 500 тисячах - памʼять і секунди.
Що `LazyCollection` побудована на генераторах (`yield`) і що її дають `cursor()`, `lazy()`, `lazyById()`, а `chunk()`/`chunkById()` роблять те саме через явні пачки.
Що `where()` на колекції порівнює нестрого (`==`), а строгий варіант називається `whereStrict()`; на моделях це ще й ігнорує касти й акцесори, яких немає в атрибутах.
Що після фільтрації в PHP ламається `paginate()`: пагінатор рахує `total` по базі, а сторінка показує вже прорідений набір.
`Order::all()->count()` замість `Order::count()`: перше читає й гідрує всю таблицю заради одного числа, друге - `select count(*)`.
Забути `values()` після `filter()` або `reject()`: ці методи зберігають початкові ключі, тому `toJson()` дає `{"1":…,"3":…}` замість масиву, і фронт отримує обʼєкт.
Вважати `cursor()` універсальним ліком від памʼяті: на MySQL і PostgreSQL PDO за замовчуванням буферизує весь результат у памʼяті PHP, економія йде лише на моделях. Для справді великих вибірок беруть `lazyById()`.
Робити `$orders->contains(fn ($o) => $o->user_id === $u->id)` всередині циклу по користувачах: це квадрат від кількості рядків. Один `keyBy('user_id')` або `groupBy('user_id')` перетворює пошук на звертання за ключем.
Сортувати в PHP через `sortByDesc('created_at')` те, що база віддала б за індексом через `orderBy`, а потім ще й `take(20)` після завантаження всіх рядків.
Змінювати або видаляти рядки всередині `chunk()` за тією ж умовою, за якою йде вибірка: `offset` зсувається, і частина записів пропускається. Для цього є `chunkById()`.
Тягнути `Model::all()` заради двох колонок: кожна модель - це обʼєкт з атрибутами, оригіналами й кастами, тоді як `DB::table()` або `->toBase()` дають прості `stdClass`.
ПОРАДА

Проведіть межу вголос: база фільтрує, рахує й сортує, колекція форматує те, що вже приїхало. Далі назвіть симптом, за яким це видно в коді: `all()`, `get()` без `where`, `count()` чи `sum()` після завантаження. І додайте, що для великих обсягів існує `LazyCollection` з генераторами, де ланцюжок обробляє один елемент за раз замість того, щоб тримати весь набір.

оновлено 10 вересня 2026 · ліцензія CC-BY-SA-4.0 Знайшли неточність? Напишіть →
ПЕРЕВІРТЕ СЕБЕ

Перший варіант описує головну помилку теми: `all()` виконує `select *` без `where` і гідрує всю таблицю, а фільтр колекції працює вже над масивом у памʼяті. `filter()` навпаки зберігає ключі, тому `[1 => 2, 3 => 4]` серіалізується в обʼєкт, і саме через це ставлять `values()`. `sortBy()` і `groupBy()` у `LazyCollection` реалізовані через `passthru()`: тип лишається лінивим, але всередині викликається `collect()` і весь набір опиняється в памʼяті. Правильна відповідь називає реальну різницю між `cursor()` (одна вибірка, генератор поверх неї) і `lazy()`/`lazyById()` (пачки запитів).