---
title: 'Eloquent і великі дані: chunk, lazy та cursor | DevSense'
description: "Як обробляти й вивантажувати сотні тисяч рядків у Laravel без нестачі пам'яті: N+1 і preventLazyLoading, chunk проти chunkById, lazy та cursor, буферизація PDO, стримінг CSV, Query Builder і сирий SQL для звітів."
faq:
    - { question: 'Чим chunk() відрізняється від cursor() у Laravel?', answer: "chunk() виконує багато запитів з LIMIT і OFFSET і передає в замикання колекцію з N моделей, тому підтримує eager loading. cursor() виконує один запит і створює моделі по одній через генератор, але не вміє eager loading, а драйвер PDO за замовчуванням однаково тримає весь сирий результат запиту в пам'яті." }
    - { question: 'Чому chunk() пропускає записи, якщо оновлювати їх усередині циклу?', answer: 'chunk() гортає результат через OFFSET. Якщо цикл змінює колонку, за якою фільтрується запит (наприклад, processed = false → true), оброблені рядки випадають із вибірки, і наступний OFFSET перескакує через необроблені. chunkById() гортає за первинним ключем (WHERE id > останній) і такої проблеми не має.' }
    - { question: "Чому cursor() однаково призводить до нестачі пам'яті?", answer: 'Моделі створюються по одній, але PDO за замовчуванням отримує весь результат запиту від сервера й зберігає його в буфері клієнта — так працюють і pdo_mysql (буферизовані запити), і pdo_pgsql. На мільйонах рядків цей буфер не вміщується в memory_limit. Для таких обсягів використовуйте lazyById() або серверний курсор бази даних.' }
    - { question: 'Коли варто відмовитися від Eloquent на користь Query Builder або сирого SQL?', answer: "Коли моделі не потрібні: агрегати й звіти (GROUP BY, віконні функції), масові UPDATE та INSERT ... SELECT, експорт пласких даних. Eloquent створює об'єкт на кожен рядок, застосовує касти й події — на сотнях тисяч рядків це зайві секунди й сотні мегабайтів. Водночас події моделей і observers під час масових операцій не викликаються." }
published: '2026-10-03'
---
# Eloquent і великі дані: N+1, chunk, lazy та cursor без нестачі пам'яті

Звіт «усі замовлення за рік у CSV» працює на стейджингу, де п'ять тисяч замовлень, і падає в продакшені з `Allowed memory size of 536870912 bytes exhausted`. Нічна команда перерахунку бонусів обробляє половину клієнтів і мовчки завершується — а наступного дня з'ясовується, що кожного другого клієнта пропущено. Сторінка зі списком із п'ятдесяти статей робить двісті запитів до бази.

Усі три історії — про ту саму навичку: розуміти, що Eloquent робить із базою та пам'яттю, і обирати правильний інструмент під обсяг даних. У цій статті — N+1, різниця між `chunk`, `chunkById`, `lazy` та `cursor`, чому `cursor` не рятує від нестачі пам'яті, як стримити експорт на мільйон рядків і коли чесніше написати SQL.

**Пов'язані матеріали:** [Оптимізація запитів](database-query-optimization) · [Індекси баз даних](database-indexes-deep-dive) · [PostgreSQL для дашбордів](postgresql-for-dashboards) · [Черги Laravel у продакшені](laravel-queues-production)

## Зміст

* [Звідки береться нестача пам'яті](#why-memory)
* [N+1: сотні запитів замість двох](#n-plus-one)
* [Карта методів для великих вибірок](#methods)
* [chunk() і пастка OFFSET](#chunk)
* [lazy() і lazyById(): потік замість пачок](#lazy)
* [cursor() і буферизація PDO](#cursor)
* [Експорт CSV на мільйон рядків](#export)
* [Коли потрібен Query Builder або сирий SQL](#query-builder)
* [Як вимірювати](#measure)
* [Часті помилки](#common-mistakes)
* [Чеклист](#checklist)
* [Квіз для самоперевірки](#self-test-quiz)

---

<a id="why-memory"></a>
## Звідки береться нестача пам'яті

`Order::where('year', 2026)->get()` робить три речі:

1. Виконує запит і отримує **всі рядки** результату в пам'ять PHP.
2. Створює **об'єкт моделі на кожен рядок**: атрибути, копія початкових значень для відстеження змін, касти, зв'язки.
3. Складає моделі в **колекцію**, яка живе, доки жива змінна.

Модель Eloquent займає в пам'яті в рази більше, ніж сам рядок таблиці. Десять тисяч моделей — зазвичай не проблема, мільйон — гарантований вихід за `memory_limit`. Тому для великих обсягів мета одна: **ніколи не тримати в пам'яті весь результат одразу** — ні моделей, ні сирих рядків.

---

<a id="n-plus-one"></a>
## N+1: сотні запитів замість двох

Класика: список статей з іменами авторів.

```php
$articles = Article::latest()->take(50)->get();

foreach ($articles as $article) {
    echo $article->author->name; // one extra query per article
}
```

Один запит за статтями плюс по запиту на автора кожної статті — 51 запит. Додайте теги й кількість коментарів, і сторінка робить двісті звернень до бази. Рішення — **eager loading**:

```php
$articles = Article::latest()
    ->with(['author:id,name', 'tags'])
    ->withCount('comments')
    ->take(50)
    ->get();
```

Тепер запитів чотири: статті, автори, теги (з проміжною таблицею) і підрахунок коментарів підзапитом. `author:id,name` завантажує лише потрібні колонки — первинний ключ `id` вказувати обов'язково, інакше Eloquent не зіставить авторів зі статтями.

Щоб N+1 не повертався непомітно, забороніть ліниве завантаження поза продакшеном:

```php
// app/Providers/AppServiceProvider.php
use Illuminate\Database\Eloquent\Model;

public function boot(): void
{
    Model::preventLazyLoading(! $this->app->isProduction());
}
```

У розробці й тестах звернення до незавантаженого зв'язку кине виняток, а в продакшені код працюватиме далі. У свіжих версіях Laravel є й протилежний підхід — `Model::automaticallyEagerLoadRelationships()`: під час першого звернення до зв'язку він довантажується одразу для всіх моделей колекції. Це зручна страховка, але явний `with()` залишається зрозумілішим: із коду видно, які дані потрібні сторінці.

---

<a id="methods"></a>
## Карта методів для великих вибірок

| Метод | Запитів | У пам'яті одночасно | Eager loading | Безпечний при зміні фільтра |
|-------|----------|-----------------------|---------------|---------------------------------|
| `get()` | 1 | Усі моделі | Так | — |
| `chunk(N)` | Багато (`LIMIT/OFFSET`) | N моделей | Так | **Ні** |
| `chunkById(N)` | Багато (`WHERE id > ?`) | N моделей | Так | Так |
| `lazy(N)` | Багато (`LIMIT/OFFSET`) | N моделей, віддаються по одній | Так | **Ні** |
| `lazyById(N)` | Багато (`WHERE id > ?`) | N моделей, віддаються по одній | Так | Так |
| `cursor()` | 1 | 1 модель + весь сирий результат у буфері PDO | **Ні** | Так |

Правило за замовчуванням для фонових задач і команд: **`lazyById()`** або **`chunkById()`**. Вони обмежують пам'ять, підтримують `with()` і не пропускають записи.

---

<a id="chunk"></a>
## chunk() і пастка OFFSET

`chunk()` розбиває вибірку на сторінки через `LIMIT` і `OFFSET`:

```php
Customer::where('bonus_recalculated', false)
    ->chunk(1000, function (Collection $customers) {
        foreach ($customers as $customer) {
            $customer->recalculateBonus(); // sets bonus_recalculated = true
        }
    });
```

Цей код обробить приблизно половину клієнтів. Перша пачка — рядки 1–1000, після обробки вони перестають підпадати під `bonus_recalculated = false`. Другий запит просить `OFFSET 1000` — але вибірка вже зсунулася на тисячу рядків, і клієнтів 1001–2000 пропущено. Помилки немає, команда завершується успішно.

`chunkById()` гортає за первинним ключем: кожен наступний запит — `WHERE id > :last_id ORDER BY id LIMIT 1000`. Зсув вибірки йому не страшний:

```php
Customer::where('bonus_recalculated', false)
    ->chunkById(1000, function (Collection $customers) {
        foreach ($customers as $customer) {
            $customer->recalculateBonus();
        }
    });
```

В `OFFSET` є й друга проблема — продуктивність. Щоб віддати `OFFSET 900000 LIMIT 1000`, база однаково читає й відкидає 900 000 рядків. Останні пачки виконуються в рази довше за перші. Пагінація за ключем (keyset) використовує індекс первинного ключа й коштує однаково на будь-якій глибині.

> [!WARNING]
> **Групуйте умови з `orWhere`.** `chunkById()` і `lazyById()` додають свою умову `id > ?`. Якщо ваш запит містить `orWhere`, без дужок вийде `a = 1 OR b = 2 AND id > 100`, і пагінація зламається. Обгортайте свої умови в замикання:
>
> ```php
> Customer::where(function ($query) {
>     $query->where('tier', 'gold')->orWhere('lifetime_cents', '>', 1_000_000);
> })->chunkById(1000, fn (Collection $customers) => /* ... */);
> ```

---

<a id="lazy"></a>
## lazy() і lazyById(): потік замість пачок

`lazy()` усередині робить те саме, що `chunk()`, але повертає `LazyCollection` — потік моделей, яким можна йти звичайним `foreach` і застосовувати методи колекцій:

```php
Customer::where('newsletter', true)
    ->with('subscription')
    ->lazyById(1000)
    ->filter(fn (Customer $customer) => $customer->subscription?->isActive())
    ->each(fn (Customer $customer) => SendDigest::dispatch($customer->id));
```

Методи `LazyCollection` виконуються ліниво: `filter` і `each` обробляють моделі в міру надходження, а в пам'яті перебуває лише поточна пачка з 1000 штук. `with()` працює — зв'язки довантажуються для кожної пачки окремим запитом. Для обходу у зворотному порядку є `lazyByIdDesc()`.

Код з `lazyById()` читається як звичайний цикл, тому в нових задачах його зручніше використовувати, ніж `chunkById()` із замиканням.

---

<a id="cursor"></a>
## cursor() і буферизація PDO

`cursor()` виконує **один** запит і через генератор створює моделі по одній:

```php
foreach (Order::where('status', 'paid')->cursor() as $order) {
    // only one Order model is hydrated at a time
}
```

Виглядає ідеально, але є два обмеження.

**Немає eager loading.** У пам'яті лише одна модель, тому `with()` не застосовується. Звернення до `$order->customer` усередині циклу — це знову N+1, причому на всю вибірку.

**Весь сирий результат однаково в пам'яті.** PDO за замовчуванням забирає результат запиту від сервера повністю й зберігає його в буфері клієнта:

* **pdo_mysql** працює в режимі буферизованих запитів (`PDO::MYSQL_ATTR_USE_BUFFERED_QUERY = true`);
* **pdo_pgsql** отримує від libpq весь результат запиту одразу.

Моделі створюються по одній, але масив сирих рядків на мільйон записів лежить у пам'яті процесу — і рано чи пізно впирається в `memory_limit`. Документація Laravel прямо рекомендує для дуже великих обсягів `lazy()` замість `cursor()`.

Якщо потрібен саме один прохід без пагінації, є два чесні способи стримінгу.

**MySQL: небуферизований запит на окремому з'єднанні.**

```php
// config/database.php — a dedicated connection for exports
'mysql_unbuffered' => array_merge(config('database.connections.mysql'), [
    'options' => [PDO::MYSQL_ATTR_USE_BUFFERED_QUERY => false],
]),
```

Доки результат не дочитано, з'єднання зайняте: виконати через нього інший запит не можна. Тому таке з'єднання використовують лише для читання потоку, а записи роблять через основне.

**PostgreSQL: серверний курсор.**

```php
DB::transaction(function () {
    DB::statement(
        'DECLARE export_cursor NO SCROLL CURSOR FOR
         SELECT id, customer_id, total_cents, created_at FROM orders WHERE created_at >= ?',
        [now()->startOfYear()],
    );

    while ($rows = DB::select('FETCH 5000 FROM export_cursor')) {
        foreach ($rows as $row) {
            // stream $row somewhere
        }
    }
});
```

Курсор живе на сервері, а PHP отримує дані порціями по 5000 рядків. Курсор без `WITH HOLD` існує лише всередині транзакції. Тримати її відкритою годинами не варто: довга транзакція заважає VACUUM очищати старі версії рядків.

На практиці `lazyById()` закриває 95% задач, а серверний курсор потрібен, коли сортування не за первинним ключем або запит складний і дорого виконувати його заново для кожної пачки.

---

<a id="export"></a>
## Експорт CSV на мільйон рядків

Типовий експорт без нестачі пам'яті — це три прийоми: вибирати лише потрібні колонки, не створювати моделі й писати результат у потік, а не в рядок.

```php
// app/Http/Controllers/OrderExportController.php
use Symfony\Component\HttpFoundation\StreamedResponse;

public function __invoke(Request $request): StreamedResponse
{
    $year = (int) $request->validate(['year' => ['required', 'integer', 'min:2020']])['year'];

    return response()->streamDownload(function () use ($year) {
        $out = fopen('php://output', 'w');
        fputcsv($out, ['id', 'customer_id', 'total', 'created_at']);

        DB::table('orders')
            ->select(['id', 'customer_id', 'total_cents', 'created_at'])
            ->whereYear('created_at', $year)
            ->lazyById(5000)
            ->each(function (object $row) use ($out) {
                fputcsv($out, [
                    $row->id,
                    $row->customer_id,
                    number_format($row->total_cents / 100, 2, '.', ''),
                    $row->created_at,
                ]);
            });

        fclose($out);
    }, "orders-{$year}.csv", ['Content-Type' => 'text/csv']);
}
```

* `DB::table()` замість моделі — на виході легкі об'єкти `stdClass`, без кастів і відстеження змін. Для запиту, побудованого на моделі, те саме дає `->toBase()`.
* `select()` обмежує колонки: `SELECT *` тягне `TEXT`-поля, які в CSV не потрібні.
* `streamDownload()` надсилає дані клієнтові в міру генерації, відповідь не збирається в пам'яті.

> [!NOTE]
> **Експорт, довший за 30 секунд, — у чергу.** HTTP-запит упреться в таймаути PHP-FPM, nginx або балансувальника. Великий експорт краще генерувати в задачі черги у файл на диску чи в S3 і надсилати користувачеві посилання. Подробиці про таймаути й пам'ять воркерів — у статті про [черги в продакшені](laravel-queues-production#heavy-jobs).

Зверніть увагу на `whereYear()`: функція над колонкою заважає використати індекс за `created_at`. Для великих таблиць надійніший діапазон — `whereBetween('created_at', [$from, $to])` (докладніше в статті про [індекси](database-indexes-deep-dive)).

---

<a id="query-builder"></a>
## Коли потрібен Query Builder або сирий SQL

Eloquent зручний, коли потрібні моделі: бізнес-логіка, зв'язки, події. Для роботи з даними «пачкою» він часто зайвий.

**Агрегати рахуйте в базі, а не в PHP.**

```php
// Bad: loads every order into PHP to sum one column
$total = Order::where('status', 'paid')->get()->sum('total_cents');

// Good: the database returns one number
$total = Order::where('status', 'paid')->sum('total_cents');

// Reports: grouping and window functions belong in SQL
$daily = DB::table('orders')
    ->selectRaw('date(created_at) as day, count(*) as orders, sum(total_cents) as revenue')
    ->where('created_at', '>=', now()->subDays(30))
    ->groupByRaw('date(created_at)')
    ->orderBy('day')
    ->get();
```

**Масові зміни — одним запитом.**

```php
// One UPDATE instead of loading and saving 200 000 models
Order::where('status', 'pending')
    ->where('created_at', '<', now()->subDays(30))
    ->update(['status' => 'expired']);

// Batch upsert for imports: one statement per batch of rows
DB::table('product_prices')->upsert(
    $rows,                       // array of ['sku' => ..., 'price_cents' => ..., 'updated_at' => ...]
    uniqueBy: ['sku'],
    update: ['price_cents', 'updated_at'],
);
```

Масовий `update()` через Eloquent-конструктор запитів проставить `updated_at`, але **не викличе події моделей і observers**, не застосує мутатори й не перевірить `$fillable`. Якщо на події `updated` висить логіка (очищення кешу, аудит), її потрібно виконати явно.

**Складні запити — чесним SQL із прив'язкою параметрів.** `INSERT ... SELECT`, CTE та віконні функції через `DB::select()` або `selectRaw()` читаються краще, ніж ланцюжок із двадцяти методів конструктора запитів. Головне правило — значення лише через плейсхолдери:

```php
// Safe: values are bound, never concatenated into SQL
$rows = DB::select(
    'SELECT customer_id, sum(total_cents) AS spent
     FROM orders WHERE created_at >= ? GROUP BY customer_id HAVING sum(total_cents) > ?',
    [$from, 100_000],
);
```

Підставлення користувацького введення в рядок SQL — прямий шлях до SQL-ін'єкції, навіть у «внутрішньому» звіті (докладніше — у статті про [вебатаки](web-attacks-and-prevention)).

---

<a id="measure"></a>
## Як вимірювати

Не вгадуйте — вимірюйте на реалістичному обсязі даних:

```php
$start = hrtime(true);
DB::enableQueryLog();

// ... code under test ...

logger()->info('export stats', [
    'queries' => count(DB::getQueryLog()),
    'peak_mb' => round(memory_get_peak_usage(true) / 1024 / 1024, 1),
    'ms' => (int) ((hrtime(true) - $start) / 1_000_000),
]);
```

* `memory_get_peak_usage(true)` показує пік, а не поточне значення — саме пік упирається в `memory_limit`.
* Лог запитів вмикайте лише на час вимірювання. У довгих командах він сам стає витоком: кожен запит зберігається в масив. З тієї ж причини Telescope і Debugbar, які збирають запити, роздувають пам'ять довгоживучих воркерів.
* Для запитів, що лишилися повільними після виправлення N+1, дивіться план виконання — `EXPLAIN ANALYZE` (докладно в [майстер-класі з оптимізації запитів](database-query-optimization)).

---

<a id="common-mistakes"></a>
## Типові помилки

**1. `chunk()` зі зміною колонки з умови.**
Половина записів мовчки пропускається. Використовуйте `chunkById()` або `lazyById()`.

**2. `cursor()` як ліки від нестачі пам'яті.**
Моделі створюються по одній, але сирий результат буферизує PDO. На мільйонах рядків — той самий OOM, лише пізніше.

**3. Звернення до зв'язків усередині `cursor()`.**
Eager loading не працює, виходить N+1 на всю вибірку.

**4. `get()->sum()`, `get()->count()`, `all()->filter()`.**
Агрегати й фільтри, пораховані в PHP, тягнуть у пам'ять усю таблицю. Рахуйте в базі.

**5. `orWhere` без групування разом із `chunkById()`.**
Умова `id > ?` склеюється з вашим `OR`, і пагінація ламається.

**6. Масовий `update()` там, де потрібні події моделі.**
Observers і події не викликаються — кеш не скидається, аудит не пишеться.

**7. Увімкнений лог запитів у довгій команді.**
Кожен запит осідає в пам'яті. Вмикайте лог лише для вимірювань.

---

<a id="checklist"></a>
## Чеклист

1. Списки зі зв'язками завантажуються через `with()` і `withCount()`; `preventLazyLoading()` увімкнено поза продакшеном.
2. Вибірки, більші за кілька тисяч рядків, обробляються через `lazyById()` або `chunkById()`, а не `get()`.
3. `chunk()` і `lazy()` не використовуються, якщо цикл змінює колонки з умови запиту.
4. `cursor()` застосовується свідомо: без зв'язків і з розумінням буферизації PDO.
5. Експорти вибирають лише потрібні колонки, працюють без моделей і пишуть у потік.
6. Довгі експорти й імпорти виконуються в черзі, а не в HTTP-запиті.
7. Агрегати й масові зміни виконуються в базі одним запитом.
8. Сирий SQL використовує лише прив'язку параметрів.
9. Пікову пам'ять і кількість запитів перевірено на реалістичному обсязі даних.

---

## Підсумок

Eloquent не повільний — він робить рівно те, що попросили: завантажує все, що повернув запит, і створює об'єкт на кожен рядок. Великі дані потребують іншого питання: «скільки цього опиниться в пам'яті одночасно?» Для обробки — `lazyById()`, для звітів — агрегати в базі, для експорту — потік без моделей, для масових змін — один запит. А `chunk()` зі змінюваною умовою та `cursor()` на мільйонах рядків хай залишаються в списку частих помилок, а не у вашому продакшені.

---

<a id="self-test-quiz"></a>
## Квіз для самоперевірки

### Питання 1: Команда оновлює `processed = true` у записів, вибраних за умовою `processed = false`, через `chunk(500)`. Що станеться?
- А) Усі записи буде оброблено, але повільніше, ніж із `chunkById()`.
- Б) Приблизно половину записів буде пропущено: вибірка зсувається, а `OFFSET` зростає.
- В) Laravel кине виняток про конкурентну зміну даних.

<details>
<summary>Показати правильну відповідь</summary>

**Правильна відповідь: Б**
Після обробки першої пачки ці рядки перестають підпадати під умову, і наступна сторінка з `OFFSET 500` перескакує через ще не оброблені записи. `chunkById()` гортає за `id > останній` і не залежить від зсуву вибірки.
</details>

### Питання 2: Чому `cursor()` може вичерпати пам'ять на вибірці з п'яти мільйонів рядків, хоча моделі створюються по одній?
- А) Генератори PHP зберігають усі раніше видані значення.
- Б) Драйвер PDO за замовчуванням отримує весь результат запиту й зберігає його в клієнтському буфері.
- В) `cursor()` автоматично завантажує всі зв'язки моделей.

<details>
<summary>Показати правильну відповідь</summary>

**Правильна відповідь: Б**
І pdo_mysql у буферизованому режимі, і pdo_pgsql забирають результат запиту повністю. Для дуже великих обсягів використовують `lazyById()`, небуферизоване з'єднання MySQL або серверний курсор PostgreSQL.
</details>

### Питання 3: Потрібно перевести 300 000 прострочених замовлень у статус `expired`. На події `updated` моделі `Order` висить скидання кешу. Який підхід коректний?
- А) Один запит `Order::where(...)->update(['status' => 'expired'])` — події спрацюють автоматично.
- Б) Один масовий `update()` і явне скидання кешу після нього, або `lazyById()` зі збереженням моделей, якщо на кожен запис потрібна логіка події.
- В) `Order::where(...)->get()->each->update(...)` — це найшвидший варіант.

<details>
<summary>Показати правильну відповідь</summary>

**Правильна відповідь: Б**
Масовий `update()` не викликає події моделей і observers. Якщо їхня логіка потрібна, її виконують явно після запиту або обробляють моделі потоком через `lazyById()`. Варіант В завантажує всі 300 000 моделей у пам'ять.
</details>