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

Eloquent чи Query Builder: де межа й що обирати для звітів?

Eloquent і Query Builder генерують той самий SQL, різниця починається після того, як рядки прийшли: `get()` на моделі створює обʼєкт на рядок, копіює атрибути в `$original` і тримає масив `$relations`, а базовий білдер віддає `stdClass`. Для звітів і вивантажень беруть `toBase()` або `DB::table()`, агрегують у SQL, а великі набори проганяють через `chunkById()`/`lazyById()`, не через `chunk()`.

Сторінка зі звітом за квартал падає на memory_limit, хоча SQL відпрацьовує за 200 мс. Що поміняєте першим?
Що робить `toBase()` і чи повернуться після нього видалені через SoftDeletes рядки?
У `chunk(1000)` колбек проставляє `status = 'processed'`, після прогону оброблено рівно половину замовлень. Чому?
Чим `cursor()` відрізняється від `lazy()`, якщо обидва повертають `LazyCollection`?
Eloquent Query Builder toBase chunk cursor звіти

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\Builder` тримає всередині `Query\Builder` і проксіює до нього невідомі методи: SQL однаковий, платите ви за гідратацію - `hydrate()` викликає `newFromBuilder()` на кожен рядок, кладе значення в `$attributes` і робить їх копію в `$original` для `isDirty()`.
Що `toBase()` - це `applyScopes()->getQuery()`, тобто глобальні скоупи (зокрема SoftDeletes) лишаються, а моделі не створюються; `DB::table()` не знає про скоупи взагалі й покаже видалені рядки.
Що агрегати рахує БД: `count()`, `sum()`, `exists()`, `pluck()` на Eloquent-білдері й так ідуть у базовий білдер, і тягнути колекцію моделей заради `->sum('total')` у PHP не треба.
Що `chunk()` ходить через `LIMIT/OFFSET`, тому зміна рядків усередині колбека зсуває вибірку; для обходу з мутацією є `chunkById()`, `lazyById()`, `eachById()`.
Що значення в raw-вирази передають прив'язками (`whereRaw('total > ?', [$min])`), а імена колонок і напрям сортування прив'язати неможливо - їх беруть з білого списку.
Що моделі потрібні там, де працюють каст, аксесори, події, policy й звʼязки: запис, бізнес-логіка, картка обʼєкта. Звіт таких речей не використовує.
Витягти `Order::all()` і порахувати `->sum('total')` у PHP: база вміє це одним рядком, а ви привезли 300 тисяч обʼєктів у памʼять.
Вважати, що `DB::table('posts')` - це просто швидший `Post::query()`: зникають глобальні скоупи, `SoftDeletingScope`, каст дат, аксесори й події моделі, тож у звіт тихо потрапляють видалені записи.
Замінити `chunk()` на `cursor()` заради памʼяті й здивуватися, що на MySQL пік памʼяті майже не впав: буферизований PDO уже привіз увесь результат у процес.
Використати `chunk()` для обробки з оновленням статусу: рядки виходять із вибірки, офсет наступної сторінки перестрибує через необроблені записи.
Склеїти raw-рядок з `$request`: `orderByRaw('created_at '.$request->dir)` або `whereRaw("email = '$email'")` - друге це SQL-ін'єкція, перше теж, бо після `desc` можна дописати кому й підзапит.
Писати `->where(DB::raw('lower(email)'), $email)` і думати, що індекс спрацює: функція на колонці вимикає звичайний B-tree індекс, потрібен функціональний індекс або окрема колонка.
У Laravel 10+ робити `(string) DB::raw('...')`: `Expression` більше не має `__toString()`, значення дістає `getValue($grammar)`.
ПОРАДА

Скажіть, що межу проводять не за «зручно/швидко», а за тим, чи потрібна вам поведінка моделі. Запис, бізнес-правила, події, звʼязки - Eloquent. Звіт, експорт, міграція даних - `toBase()` або `DB::table()`, агрегація в SQL, обхід через `chunkById()`. І одразу додайте, що `toBase()` зберігає глобальні скоупи, а `DB::table()` - ні: цієї деталі junior зазвичай не знає.

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

`toBase()` - це `applyScopes()->getQuery()`, тобто скоупи застосовуються, і soft-deleted рядки лишаються відфільтрованими; знімає їх `DB::table()` або `withTrashed()`. `chunk()` гортає через `LIMIT/OFFSET`, а `chunkById()` - через `where id > ?`, тож при зміні рядків у колбеку набори різні: `chunk()` пропускає записи. `whereRaw()` вставляє рядок у SQL дослівно, прив'язки треба передавати другим аргументом. Правильний варіант описує реальну межу `cursor()`: він економить обʼєкти PHP, а не памʼять під сирий результат, бо pdo_mysql за замовчуванням буферизує вибірку.