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 zostawiajid.
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żywajchunkById(). 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()iupsert()nie uruchamiają eventów modelu (created/updated) ani nie ustawiającreated_at/updated_atautomatycznie — 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
select()tylko potrzebnych kolumn — zawsze.- Duże zbiory:
chunkById()(modyfikacje) albocursor()(eksport, stała pamięć). - Agregacje w bazie:
withCount,withSum,withAvg. pluck()/value()zamiast modeli, gdy potrzebujesz pojedynczych wartości.- Filtrowanie po relacjach: zmierz
whereHasvswhereIn+ podzapytanie. - Operacje masowe:
insert,upsert,update— nie pętlesave(). - Eager loading z kolumnami:
with(['items:id,order_id,sku']). - 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.