ix
ix

informatix

EN version Porozmawiajmy o projekcie
Strona główna / Blog / Wydajność Eloquent — chunk, cursor i optymalizacja zapytań [...
• 8 min czytania •

Wydajność Eloquent — chunk, cursor i optymalizacja zapytań [2026]

Eloquent domyślnie ładuje więcej, niż potrzebujesz. Wzorce, które skracają zapytania z sekund do milisekund: chunkById, cursor, select, withCount, upsert i podzapytania — z pułapkami, które kosztują najwięcej.

Wydajność Eloquent — chunk, cursor i optymalizacja zapytań [2026]
Aplikacje webowe, Web development

Eloquent jest jednym z najwygodniejszych ORM-ów w ekosystemie PHP — i jednocześnie najczęstszym źródłem problemów wydajnościowych w aplikacjach Laravel. Nie dlatego, że jest wolny, ale dlatego, że domyślnie robi więcej, niż potrzebujesz: ładuje wszystkie kolumny, wszystkie rekordy i tworzy pełne modele tam, gdzie wystarczyłby strumień danych. Ten artykuł to praktyczny zestaw wzorców, które w realnych projektach skracają czas zapytań z sekund do milisekund.

Najważniejsze: Najważniejsze — wybieraj kolumny (select), przetwarzaj duże zbiory strumieniowo (chunkById, cursor), licz i sumuj w bazie (withCount, withSum), a unikaj wzorców „pobierz wszystko i filtruj w PHP". Wzorce z tego artykułu stosuj razem z eliminacją N+1 — dopiero wtedy baza przestaje być wąskim gardłem.

  • ## Zasada pierwsza: nie pobieraj tego, czego nie używasz

Domyślne Model::all() albo Model::get() ładuje wszystkie kolumny wszystkich rekordów — łącznie z polami TEXT, JSON i BLOB, które potrafią ważyć kilobajty na rekord. Jeśli z listy 10 000 zamówień potrzebujesz pięciu kolumn, reszta to transfer i pamięć wyrzucone w błoto.

// ŹLE: pełne modele z całym payloadem
$orders = Order::all();

// DOBRZE: tylko potrzebne kolumny
$orders = Order::query()
    ->select('id', 'number', 'total', 'status', 'created_at')
    ->get();
  • Uwaga na pułapkę: jeśli pominiesz klucz główny, część funkcji Eloquenta (relacje, fresh(), save()) przestanie działać. Zawsze zostawiaj id.

Gdy potrzebujesz pojedynczej wartości albo mapy, nie buduj modeli w ogóle

// Lista e-maili — bez modeli
$emails = User::query()->where('active', true)->pluck('email');

// Pojedyncza wartość
$total = Order::query()->where('status', 'paid')->sum('total');

pluck() i sum()/avg()/max() wykonują pracę w bazie i zwracają prymitywy — to często 5–10× mniejszy transfer niż pobranie kolekcji modeli.

chunk, chunkById i cursor — duże zbiory bez wyczerpania pamięci

Order::all() na tabeli z milionem rekordów to milion modeli w pamięci PHP — typowo kilka GB. Nigdy tego nie rób. Trzy narzędzia do strumieniowego przetwarzania:

Metoda Jak działa Kiedy używać
chunk(1000) Partie po 1000 rekordów, offset rośnie Proste odczyty, gdy dane się nie zmieniają
chunkById(1000) Partie po 1000, klucz WHERE id > ostatnie_id Gdy rekordy mogą być modyfikowane w trakcie
cursor() Jeden rekord na raz, jeden „otwarty" wynik Minimalne zużycie pamięci, najwolniejszy
// Bezpieczne przetwarzanie przy modyfikacji rekordów w trakcie
Order::query()
    ->where('status', 'pending')
    ->chunkById(1000, function ($orders) {
        foreach ($orders as $order) {
            ProcessOrder::dispatch($order->id);
        }
    });

// Eksport 500 000 wierszy do CSV — pamięć stała (jeden rekord)
foreach (Order::query()->cursor() as $order) {
    fputcsv($handle, [$order->number, $order->total]);
}

Uwaga: chunk() z offsetem gubi rekordy, gdy modyfikujesz dane w trakcie (usunięcie rekordu z partii przesuwa offset). Dlatego przy jakichkolwiek zmianach używaj chunkById(). To najczęstszy błąd w skryptach importu i migracji danych.

Agregacje w bazie zamiast w PHP

  • Klasyk: pobierz 1000 zamówień z relacją i policz w pętli. Każde ->count() na kolekcji to CPU i pamięć; każde ->count() na relacji to N+1.
// ŹLE: N+1 i liczenie w PHP
foreach (Order::all() as $order) {
    echo $order->items->count();
}

// DOBRZE: jedno zapytanie z podzapytaniem
$orders = Order::withCount('items')->get();
$orders->each(fn ($order) => print($order->items_count));

// Sumy i średnie relacji
$customers = Customer::withSum('orders', 'total')
    ->withAvg('orders', 'total')
    ->get();

withCount, withSum, withAvg, withMax, withMin budują podzapytania SQL — baza robi to, w czym jest najlepsza, a PHP dostaje gotowe liczby.

whereHas, whereIn i podzapytania — filtrowanie po relacjach

whereHas() wygląda elegancko, ale pod spodem generuje EXISTS z podzapytaniem — przy dużych tabelach bez indeksów bywa kosztowny. Zanim go użyjesz, rozważ whereIn z podzapytaniem:

// whereHas — EXISTS, czytelny, czasem wolniejszy
$users = User::whereHas('orders', fn ($q) =>
    $q->where('status', 'paid')
)->get();

// whereIn z podzapytaniem — często szybszy na dużych zbiorach
$users = User::whereIn('id',
    Order::select('user_id')->where('status', 'paid')
)->get();

Obie wersje są poprawne — różnica leży w planie zapytania i indeksach. Na produkcji zweryfikuj EXPLAIN ANALYZE dla obu wariantów; techniki czytania planów opisuję w indeksy i EXPLAIN ANALYZE w Laravel.

Do relacji polimorficznych i filtrów na zagnieżdżonych danych używaj whereRelation()

Post::whereRelation('author', 'active', true)->get();

Bulk operations — insert, update, upsert

Pętla save() po tysiącu rekordów to tysiąc zapytań. Operacje masowe załatwiasz jednym zapytaniem.

// Bulk insert — jedno zapytanie
OrderItem::insert($rows);

// Bulk upsert — jedno zapytanie, aktualizuje istniejące po kluczu unikalnym
OrderItem::upsert($rows, ['order_id', 'sku'], ['qty', 'price']);

// Bulk update — jedno zapytanie UPDATE dla całego filtra
Order::where('status', 'pending')
    ->where('created_at', '<', now()->subDays(30))
    ->update(['status' => 'expired']);

Wskazówka: insert() i upsert() nie uruchamiają eventów modelu (created/updated) ani nie ustawiają created_at/updated_at automatycznie — dodaj znaczniki czasu ręcznie do danych, jeśli są potrzebne. To świadomy kompromis: zysk wydajności w zamian za obejście warstwy Eloquenta.

  • Uwaga na limity: PostgreSQL ma limit ~65 535 parametrów w jednym zapytaniu. Przy szerokich tabelach dziel insert() na partie (np. po 500–1000 wierszy).

Optymalizacja relacji — wybór przez pivot i pivot data

Relacje wiele-do-wielu z dodatkowymi danymi potrafią zaskoczyć liczbą zapytań. Domyślne withPivot() nie generuje zapytań, ale dostęp do $model->pivot->custom już może — jeśli dodałeś tam accessor. Sprawdź using() (custom pivot model) i unikaj accessorów odwołujących się do relacji.

Przy zagnieżdżonych eager loadingach pamiętaj o składni z ograniczeniem kolumn

Order::with(['items:id,order_id,sku,qty'])->get();

To jedno z najbardziej niedocenianych narzędzi: ładuje relację z wybranymi kolumnami, drastycznie zmniejszając transfer.

Podsumowanie — checklista wydajnego Eloquenta

  1. select() tylko potrzebnych kolumn — zawsze.
  2. Duże zbiory: chunkById() (modyfikacje) albo cursor() (eksport, stała pamięć).
  3. Agregacje w bazie: withCount, withSum, withAvg.
  4. pluck()/value() zamiast modeli, gdy potrzebujesz pojedynczych wartości.
  5. Filtrowanie po relacjach: zmierz whereHas vs whereIn + podzapytanie.
  6. Operacje masowe: insert, upsert, update — nie pętle save().
  7. Eager loading z kolumnami: with(['items:id,order_id,sku']).
  8. Weryfikacja na produkcji: slow query log / Pulse + EXPLAIN ANALYZE.

Te wzorce działają najlepiej razem z pozostałymi warstwami optymalizacji — od eliminacji problemu N+1, przez cache powtarzalnych zapytań, po indeksy dopasowane do zapytań. Całościowy plan znajdziesz w optymalizacji wydajności aplikacji Laravel.

Najczęściej zadawane pytania

Czy chunk() jest bezpieczny przy modyfikowaniu rekordów w trakcie?

Nie — chunk() używa offsetu, więc usunięcie lub zmiana pozycji rekordu w trakcie iteracji może pominąć część danych. Do pracy na zmieniających się danych używaj chunkById(): filtruje po WHERE id > ostatnie_id, więc modyfikacje nie przesuwają strumienia.

Kiedy używać cursor() zamiast chunk()?

Gdy liczy się pamięć, a nie czas: cursor() trzyma w pamięci jeden rekord (używa niebuforowanego wyniku PDO), ale wykonuje zapytanie dłużej i wymaga stałego połączenia z bazą w trakcie iteracji. Eksporty, generowanie raportów, przetwarzanie plików — tak; pętle z zapisami do bazy w środku — raczej chunkById().

Czy select() psuje relacje Eloquenta?

Może — jeśli pominiesz klucz obcy lub klucz główny. Przy eager loadingu relacja potrzebuje klucza łączącego (with(['items:id,order_id,sku']) — order_id musi być na liście). Przy zwykłym odczycie zostaw id, jeśli planujesz save(), fresh() albo dostęp do relacji.

withCount czy count() w PHP — co wybrać?

Zawsze withCount() przy kolekcjach. count() w pętli to N+1 (jedno zapytanie per rekord), a count() na kolekcji w PHP wymaga pobrania wszystkich rekordów. withCount() to jedno podzapytanie SQL dla całej strony wyników.

Czy upsert() działa na MySQL i PostgreSQL?

Tak — Laravel tłumaczy upsert() na INSERT ... ON CONFLICT DO UPDATE (PostgreSQL/SQLite) albo ON DUPLICATE KEY UPDATE (MySQL). Wymaga klucza unikalnego na kolumnach z drugiego argumentu. Pamiętaj, że nie uruchamia eventów modelu i nie ustawia automatycznie timestampów.

Jak znaleźć najwolniejsze zapytania Eloquenta na produkcji?

Trzy źródła: Laravel Pulse (karta slow queries — agreguje czas per zapytanie), pg_stat_statements w PostgreSQL (SUM czasu i liczba wywołań — widać też duplikaty N+1) oraz slow query log bazy. Zestaw komend i konfigurację opisuję w optymalizacji wydajności aplikacji Laravel.


👉 Baza danych jest wąskim gardłem aplikacji? Zaczynamy od analizy zapytań i planów — opisz projekt przez formularz. Zobacz też utrzymanie techniczne i SLA.

📖 Zobacz również: problem N+1 w Laravel, indeksy i EXPLAIN ANALYZE, PostgreSQL czy MySQL do Laravel oraz optymalizacja wydajności aplikacji Laravel.

KS

Krzysztof Słonina

Full stack developer i właściciel Inoventis Solutions (informatix). Od 2009 roku buduje aplikacje webowe w Laravel/PHP, integracje e-commerce i automatyzacje procesów — m.in. OneCRMApp i FamilyTimelineApp, działające na Laravel, React i Inertia.js.

O autorze
Darmowy PDF

Checklista wymagań do wyceny aplikacji

12 pytań kontrolnych, które warto przygotować przed rozmową z wykonawcą — szybsza wycena, mniej ukrytych kosztów.

Powiązane wpisy