Post::query() віддає Illuminate\Database\Eloquent\Builder, усередині якого лежить звичайний Illuminate\Database\Query\Builder; усі where, join, groupBy через __call ідуть саме до нього. SQL у двох варіантів однаковий, різниця починається після того, як PDO повернув рядки. Eloquent\Builder::get() викликає getModels(), той - hydrate(), і на кожен рядок створюється модель через newFromBuilder(): обʼєкт, масив $attributes і його копія в $original, щоб працювали isDirty() та збереження лише змінених полів, плюс порожні $relations і службові прапорці. Базовий білдер на тому ж місці віддає stdClass з PDO::FETCH_OBJ. На двадцяти рядках цю різницю ніхто не помітить, на трьохстах тисячах вона і є причиною падіння звіту на memory_limit при тому, що сам запит виконався за 200 мс.
Разом з обʼєктами ви отримуєте поведінку, за яку і люблять Eloquent: каст типів, аксесори, Carbon на датах, події saving/saved, звʼязки, глобальні скоупи. У звіті нічого з цього не використовується. Тому середина між двома підходами - toBase(): він визначений як applyScopes()->getQuery(), тобто глобальні скоупи вже застосовані (SoftDeletingScope продовжує ховати видалені рядки), а моделі не створюються. DB::table('orders') про скоупи не знає взагалі, саме тому в звітах час від часу зʼявляються записи, які користувач видалив півроку тому. Ще один шар, про який junior часто забуває: агрегати на Eloquent-білдері й так гідратацію не роблять. count(), sum(), avg(), exists() вертають скалярне значення з БД, а pluck() іде через toBase() і піднімає тимчасову модель лише тоді, коли на колонці справді висить каст або аксесор.
Для звіту головне правило простіше за вибір білдера: рахує база. selectRaw('date(created_at) as day, count(*) as orders, sum(total_cents) as revenue') з groupBy віддасть тридцять рядків замість тридцяти тисяч, і питання про гідратацію знімається саме собою. Коли рядки все-таки треба обійти всі (експорт, перерахунок, міграція даних), беруть порційний обхід. chunk(1000) виконує окремий запит на кожну порцію через LIMIT/OFFSET, і в цьому його пастка: якщо колбек змінює status або видаляє записи, рядки випадають із вибірки, офсет наступної сторінки зсувається, і обробляється приблизно половина. chunkById() замість офсета додає where id > ? з сортуванням за цією колонкою, тому мутації йому не заважають; платите ви тим, що власне сортування задати не можна, а колонка має бути унікальною й індексованою. lazy() і lazyById() роблять те саме, але повертають LazyCollection, яку зручно передавати далі без колбеків.
Окремо про cursor(), бо його часто рекомендують як «магію для памʼяті». Він виконує один запит і гідратує моделі по одній через генератор, тож обʼєкти не накопичуються. Але pdo_mysql за замовчуванням буферизований (PDO::MYSQL_ATTR_USE_BUFFERED_QUERY = true) і забирає весь результат у процес одразу; pdo_pgsql теж матеріалізує вибірку повністю, справжній серверний курсор потребує явного DECLARE CURSOR. Виходить, що на мільйоні рядків cursor() рятує від мільйона моделей, а не від мільйона рядків у памʼяті. Вимкнути буферизацію можна опцією зʼєднання, тільки тоді на цьому зʼєднанні не вийде виконати інший запит, доки курсор не вичерпано, а всередині циклу зазвичай саме це й потрібно.
З raw-виразами правило одне: значення прив'язуються, ідентифікатори - ні. whereRaw('total_cents > ?', [$min]) і havingRaw('sum(total_cents) >= ?', [$min]) безпечні; orderByRaw('created_at '.$request->dir) - ні, бо після desc дописується кома й підзапит. Імʼя колонки, напрям сортування, імʼя таблиці звіряють з білим списком. orderBy($column, $direction) дає часткову підстраховку: напрям валідується і кидає InvalidArgumentException, колонку граматика обгортає лапками, а от selectRaw, groupByRaw, orderByRaw і DB::raw() вставляються дослівно. До речі, з Laravel 10 Expression більше не має __toString(), значення дістається через getValue($grammar), і це типова помилка при апгрейді.
Чистий SQL через DB::select('...', [$from, $to]) виправданий там, де в білдера просто немає API: віконні функції, CTE й with recursive, distinct on, lateral, grouping sets. Писати їх через ланцюжок методів довше й гірше читається, ніж сам запит. У такого рішення дві ціни, і про них краще сказати одразу. Такий SQL прибитий до діалекту: date() проти created_at::date, group_concat проти string_agg, і тест на SQLite перестає щось доводити. І часовий пояс: Eloquent конвертує дати за config('app.timezone'), а групування по днях у SQL ріже за тим, що лежить у колонці, тому «продажі за 3 вересня» у звіті й у картці замовлення розходяться на день. Тримайте такі запити в одному місці, перевіряйте план через EXPLAIN і не тягніть їх у моделі. На співбесіді плюс ставлять за одне речення: моделі для запису й бізнес-логіки, білдер для читання, яке нікого не змінює.
// Сортування приходить з UI. Імʼя колонки й напрям прив'язати як параметр
// неможливо, тому вони беруться з білого списку, а не з $request.
$sortable = ['day', 'orders', 'revenue'];
$sort = in_array($request->query('sort'), $sortable, true) ? $request->query('sort') : 'day';
$direction = $request->query('dir') === 'asc' ? 'asc' : 'desc';
// Звіт: виручка по днях. Групування й суму рахує база, а не PHP.
$rows = Order::query()
->where('status', 'paid')
->whereBetween('created_at', [$from, $to])
// date() - синтаксис MySQL; у Postgres буде created_at::date.
->selectRaw('date(created_at) as day, count(*) as orders, sum(total_cents) as revenue')
// Значення в raw-вираз іде прив'язкою, а не конкатенацією.
->havingRaw('sum(total_cents) >= ?', [$minRevenueCents])
->groupBy('day')
->orderBy($sort, $direction)
// toBase() == applyScopes()->getQuery(): глобальні скоупи (SoftDeletes)
// лишаються, але рядки приходять як stdClass - без моделей,
// без кастів і без копії атрибутів у $original.
->toBase()
->get();
// Вивантаження 200k рядків у CSV: поведінка моделі тут не потрібна взагалі.
DB::table('orders')
->where('status', 'paid')
->select('id', 'number', 'total_cents', 'created_at')
// chunkById, а не chunk: chunk ходить через LIMIT/OFFSET, і якщо
// всередині колбека рядки змінювати, офсет перестрибне частину даних.
->chunkById(1000, function (Collection $orders) use ($csv): void {
foreach ($orders as $order) {
// stdClass: created_at тут рядок, а не Carbon.
fputcsv($csv, (array) $order);
}
});
// А ось тут модель обґрунтована: працюють каст, аксесори й події.
// cursor() гідратує по одній моделі, але на буферизованому MySQL
// увесь набір рядків уже привезений у памʼять процесу.
foreach (Order::where('status', 'pending_recalc')->cursor() as $order) {
$order->recalculateTotals();
}
Скажіть, що межу проводять не за «зручно/швидко», а за тим, чи потрібна вам поведінка моделі. Запис, бізнес-правила, події, звʼязки - Eloquent. Звіт, експорт, міграція даних - `toBase()` або `DB::table()`, агрегація в SQL, обхід через `chunkById()`. І одразу додайте, що `toBase()` зберігає глобальні скоупи, а `DB::table()` - ні: цієї деталі junior зазвичай не знає.