Eloquent to jeden z powodów, dla których developerzy kochają Laravel. Sprawia, że praca z bazą danych jest naturalna, czytelna i szybka w pisaniu. Ale ta wygoda niesie ze sobą pułapki - wzorce wyglądające niewinnie podczas developmentu, które po cichu niszczą wydajność pod realnym obciążeniem. Oto osiem błędów pojawiających się najczęściej podczas code review, z prostą naprawą dla każdego.
📋 Spis treści
- #1 - Brak select(): ładowanie wszystkich kolumn
- #2 - count() w pętli
- #3 - Brak indeksu na filtrowanych i sortowanych kolumnach
- #4 - Logika biznesowa w Modelu lub Kontrolerze
- #5 - Zapomnienie o withTrashed() i onlyTrashed()
- #6 - firstOrCreate vs updateOrCreate vs firstOrNew
- #7 - Brak chunk() przy masowych operacjach
- #8 - all() na kolekcji vs get() na query builderze
- ✅ Podsumowanie
#1 - Brak select(): ładowanie wszystkich kolumn
Kiedy piszesz User::all() lub User::get() bez select(), Eloquent pobiera każdą kolumnę z tabeli. To obejmuje hashe haseł, tokeny remember me, duże pola tekstowe, bloby JSON - wszystko jest pobierane z bazy, przesyłane przez sieć, hydratowane do obiektów PHP i serializowane do JSON.
// Źle - pobiera wszystkie 30 kolumn włącznie z password, remember_token, settings JSON
$users = User::all();
// Dobrze - tylko to, czego endpoint rzeczywiście potrzebuje
$users = User::select('id', 'name', 'email')->get();
Rzeczywisty koszt:
| Podejście | Kolumny | Pamięć na 1000 wierszy |
|---|---|---|
User::all() | 30 | ~4.2 MB |
User::select('id','name','email')->get() | 3 | ~0.4 MB |
Różnica rośnie wraz z szerokością tabeli. Tabela users z kolumną settings JSON lub polem bio sprawia, że all() staje się kosztowne bardzo szybko.
W API Resources - bądź precyzyjny:
// app/Http/Resources/UserResource.php
declare(strict_types=1);
namespace App\Http\Resources;
use Illuminate\Http\Resources\Json\JsonResource;
class UserResource extends JsonResource
{
public function toArray($request): array
{
return [
'id' => $this->id,
'name' => $this->name,
'email' => $this->email,
];
}
}
Nawet z Resource - jeśli załadowałeś wszystkie kolumny, niepotrzebne dane już trafiły do bazy i pamięci PHP. Najpierw select, potem transformacja.
#2 - count() w pętli
Każde wywołanie ->count() na relacji odpala nowe zapytanie SQL. W pętli po 50 postach to 50 zapytań COUNT.
// Źle - 1 + N zapytań (jedno per post)
$posts = Post::all();
foreach ($posts as $post) {
echo $post->comments()->count(); // SELECT COUNT(*) FROM comments WHERE post_id = ?
}
// Dobrze - 2 zapytania łącznie
$posts = Post::withCount('comments')->get();
foreach ($posts as $post) {
echo $post->comments_count; // Już załadowane
}
withCount() przekłada się na jedno podzapytanie dołączone do głównego selecta. Możesz ładować wiele liczników naraz:
$posts = Post::withCount(['comments', 'likes', 'shares'])->get();
// $post->comments_count, $post->likes_count, $post->shares_count
Potrzebujesz warunkowego licznika?
$posts = Post::withCount([
'comments',
'comments as approved_comments_count' => fn ($q) => $q->where('approved', true),
])->get();
Powiązane agregaty - ten sam problem, to samo rozwiązanie:
// Źle - jedno zapytanie SUM per zamówienie w pętli
foreach ($orders as $order) {
$total = $order->items()->sum('price');
}
// Dobrze - withSum() ładuje wszystko w jednym zapytaniu
$orders = Order::withSum('items', 'price')->get();
// Dostęp: $order->items_sum_price
Laravel oferuje też withAvg(), withMin(), withMax() i withExists() - wszystkie jako jednorazowe agregaty.
#3 - Brak indeksu na filtrowanych i sortowanych kolumnach
Dodanie where() w Eloquent nie sprawia, że zapytanie jest szybkie. Baza nadal skanuje każdy wiersz, jeśli nie ma indeksu na kolumnie.
// W kodzie wygląda dobrze - ale co MySQL z tym robi?
$orders = Order::where('status', 'pending')
->orderBy('created_at', 'desc')
->get();
Bez indeksu na status i created_at MySQL wykonuje pełny skan tabeli, a potem sortuje. Na tabeli z 500k wierszy to zapytanie idzie od milisekund do sekund.
Kolumny, które prawie zawsze potrzebują indeksu:
- Każdy klucz obcy (
user_id,product_id,order_id) - Każda kolumna używana w filtrach
where()w częstych zapytaniach - Każda kolumna używana w
orderBy() - Kolumny używane w regułach walidacji
unique()
Dodaj indeks złożony dla filtrów na wielu kolumnach:
// database/migrations/2026_04_01_add_index_to_orders_table.php
declare(strict_types=1);
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::table('orders', function (Blueprint $table) {
// Pokrywa WHERE status = ? ORDER BY created_at DESC
$table->index(['status', 'created_at']);
});
}
public function down(): void
{
Schema::table('orders', function (Blueprint $table) {
$table->dropIndex(['status', 'created_at']);
});
}
};
Zasada: kolejność kolumn w indeksie złożonym ma znaczenie. Kolumna równościowa (status) na pierwszym miejscu, zakresowa/sortowania (created_at) na drugim.
Weryfikacja przez EXPLAIN:
EXPLAIN SELECT * FROM orders WHERE status = 'pending' ORDER BY created_at DESC LIMIT 20;
-- key: orders_status_created_at_index ✅
-- rows: 312 (nie 94823) ✅
#4 - Logika biznesowa w Modelu lub Kontrolerze
Tłuste modele i tłuste kontrolery to code smell, który narasta z czasem. Utrudniają testowanie logiki w izolacji, ponowne użycie i ostatecznie uniemożliwiają rozumowanie o kodzie.
// Źle - logika biznesowa wewnątrz metody kontrolera
class OrderController extends Controller
{
public function store(Request $request): JsonResponse
{
$validated = $request->validate([...]);
// obliczanie rabatu w kontrolerze
$discount = 0;
if (auth()->user()->orders()->count() > 10) {
$discount = 0.1;
}
$order = Order::create([
'user_id' => auth()->id(),
'total' => $validated['total'] * (1 - $discount),
'discount' => $discount,
]);
// wysyłanie powiadomienia w kontrolerze
$order->user->notify(new OrderPlaced($order));
return response()->json($order);
}
}
Lepiej - przenieś do Query Scope (dla logiki DB) lub Action (dla logiki biznesowej):
// app/Models/Order.php
declare(strict_types=1);
namespace App\Models;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Model;
class Order extends Model
{
// Query Scope - wielokrotnie używalny filtr
public function scopePending(Builder $query): Builder
{
return $query->where('status', 'pending');
}
public function scopeForUser(Builder $query, int $userId): Builder
{
return $query->where('user_id', $userId);
}
}
// Użycie - czyste i czytelne
Order::pending()->forUser(auth()->id())->latest()->get();
// app/Actions/PlaceOrderAction.php
declare(strict_types=1);
namespace App\Actions;
use App\Models\Order;
use App\Models\User;
use App\Notifications\OrderPlaced;
class PlaceOrderAction
{
public function handle(User $user, array $data): Order
{
$discount = $user->orders()->count() > 10 ? 0.1 : 0;
$order = Order::create([
'user_id' => $user->id,
'total' => $data['total'] * (1 - $discount),
'discount' => $discount,
]);
$user->notify(new OrderPlaced($order));
return $order;
}
}
Kontroler staje się cienki i czytelny. Action jest testowalny bez kontekstu HTTP.
#5 - Zapomnienie o withTrashed() i onlyTrashed()
Soft deletes dodają kolumnę deleted_at. Gdy dodasz SoftDeletes do modelu, Eloquent automatycznie dołącza WHERE deleted_at IS NULL do każdego zapytania. Łatwo o tym zapomnieć - szczególnie budując panele admina, logi audytu lub funkcje przywracania.
// Po cichu wyklucza soft-deleted rekordy - nie zauważysz błędu
$order = Order::find($id); // Zwraca null dla usuniętych zamówień
// Dołącz soft-deleted rekordy
$order = Order::withTrashed()->find($id);
// Tylko soft-deleted rekordy
$orders = Order::onlyTrashed()->get();
// Przywróć
Order::withTrashed()->find($id)->restore();
// Trwale usuń
Order::withTrashed()->find($id)->forceDelete();
Częste pułapki:
// Zapytania relacji też stosują globalny scope
$user->orders()->count(); // Wyklucza usunięte zamówienia - czy tego chcesz?
// Jeśli potrzebujesz wszystkich zamówień włącznie z usuniętymi:
$user->orders()->withTrashed()->count();
W route model binding - jeśli route rozwiązuje model po ID i model jest soft-deleted, dostaniesz 404. Żeby pozwolić na rozwiązywanie soft-deleted modeli:
// app/Http/Controllers/Api/OrderController.php
public function show(int $id): JsonResponse
{
$order = Order::withTrashed()->findOrFail($id);
return response()->json($order);
}
Lub zarejestruj własny binding w AppServiceProvider:
Route::bind('order', fn ($value) => Order::withTrashed()->findOrFail($value));
#6 - firstOrCreate vs updateOrCreate vs firstOrNew
Te trzy metody są często mylone, co prowadzi do duplikatów rekordów, niespodziewanych aktualizacji lub zbędnych zapytań.
| Metoda | Szuka rekordu | Tworzy jeśli brak | Aktualizuje istniejący |
|---|---|---|---|
firstOrNew() | ✅ | Buduje (niezapisany) | ❌ |
firstOrCreate() | ✅ | Tworzy (zapisany) | ❌ |
updateOrCreate() | ✅ | Tworzy (zapisany) | ✅ |
// firstOrNew - znajdź lub zbuduj, NIE zapisany jeszcze
// Użyj gdy chcesz modyfikować przed zapisem
$user = User::firstOrNew(
['email' => '[email protected]'], // kryteria wyszukiwania
['name' => 'Jan', 'role' => 'guest'], // domyślne atrybuty jeśli nie znaleziono
);
$user->last_seen_at = now();
$user->save(); // Ty kontrolujesz kiedy jest zapisany
// firstOrCreate - znajdź lub utwórz natychmiast, zapisany automatycznie
// Użyj gdy domyślne wartości wystarczają i nie potrzebujesz modyfikacji
$tag = Tag::firstOrCreate(
['slug' => 'laravel'],
['name' => 'Laravel', 'color' => '#FF2D20'],
);
// updateOrCreate - znajdź i zaktualizuj, lub utwórz jeśli nie znaleziono
// Użyj dla wzorców upsert - synchronizacja zewnętrznych danych, idempotentny import
User::updateOrCreate(
['email' => '[email protected]'], // kryteria wyszukiwania
['name' => 'Jan Kowalski', 'last_login' => now()], // zawsze stosowane
);
Najczęstszy błąd: użycie firstOrCreate gdy potrzebujesz updateOrCreate, co skutkuje przestarzałymi danymi po reimporcie:
// Źle - drugi import nie zaktualizuje nazwy jeśli rekord już istnieje
User::firstOrCreate(['email' => $data['email']], ['name' => $data['name']]);
// Dobrze - nazwa jest zawsze aktualna
User::updateOrCreate(['email' => $data['email']], ['name' => $data['name']]);
#7 - Brak chunk() przy masowych operacjach
Przetwarzanie tysięcy rekordów przez ->get() ładuje wszystko do pamięci naraz. Na tabeli z 500k wierszy to błąd out of memory czekający na realizację.
// Źle - ładuje wszystkich 500k użytkowników do pamięci
User::where('subscribed', true)->get()->each(function ($user) {
$user->sendWeeklyDigest();
});
// Dobrze - przetwarza po 200 naraz, stałe użycie pamięci
User::where('subscribed', true)->chunk(200, function ($users) {
foreach ($users as $user) {
$user->sendWeeklyDigest();
}
});
chunkById() jest bezpieczniejszy gdy modyfikujesz rekordy wewnątrz chunk:
// chunk() używa OFFSET - jeśli wiersze są usuwane/wstawiane, możesz pominąć rekordy
// chunkById() używa WHERE id > last_id - spójne i bezpieczne podczas modyfikacji
User::where('needs_migration', true)->chunkById(500, function ($users) {
foreach ($users as $user) {
$user->migrateData();
}
});
lazy() i lazyById() - iteracja oparta na kursorze bez zagnieżdżania callbacków:
// Używa kursora - jeden wiersz naraz przez generator PHP
foreach (User::where('subscribed', true)->lazy(200) as $user) {
$user->sendWeeklyDigest();
}
Dla operacji czysto po stronie bazy, update() lub delete() w trybie bulk bije każdą pętlę PHP:
// 1 zapytanie zamiast 500k aktualizacji
User::where('last_login', '<', now()->subYear())->update(['is_active' => false]);
#8 - all() na kolekcji vs get() na query builderze
Ten błąd dotyczy developerów zaczynających z Eloquent - all() i get() wyglądają podobnie, ale działają zupełnie inaczej.
// Model::all() - metoda statyczna, zawsze pobiera WSZYSTKIE rekordy z tabeli
// Nie można łączyć z metodami query buildera
$users = User::all(); // SELECT * FROM users - bez WHERE, bez LIMIT
// Źle - NIE stosuje where, po cichu ignoruje (lub rzuca błąd)
$users = User::all()->where('active', true); // To jest filtr na Kolekcji, nie SQL
// Model::get() - wywołane na query builderze, respektuje wszystkie ograniczenia
$users = User::where('active', true)->orderBy('name')->limit(50)->get();
// SELECT * FROM users WHERE active = 1 ORDER BY name LIMIT 50
Collection where() vs Query Builder where():
$users = User::all(); // Już w pamięci PHP - wszystkich 10k użytkowników
// Filtruje w PHP - 10k obiektów już załadowanych, tylko ukrytych
$active = $users->where('active', true);
// vs - filtruje w MySQL, tylko aktywni użytkownicy przesłani
$active = User::where('active', true)->get();
Gdy musisz stosować filtry, zawsze używaj Query Buildera (łącz przed get()). Metod Kolekcji używaj tylko do transformacji na danych już celowo załadowanych.
all() ma jeden uzasadniony przypadek użycia - gdy naprawdę potrzebujesz wszystkich rekordów i tabela jest mała (wartości konfiguracji, role, kategorie). Dla wszystkiego generowanego przez użytkowników lub nieograniczonego, zawsze używaj get() z ograniczeniami.
✅ Podsumowanie
- Zawsze używaj
select()do pobierania tylko potrzebnych kolumn - szczególnie w odpowiedziach API - Zastąp
count()w pętlach przezwithCount(),withSum()i inne metody agregujące - Dodaj indeksy do każdej kolumny używanej w
where(),orderBy()i kluczach obcych - sprawdzaj przezEXPLAIN - Przenieś logikę biznesową z Modeli i Kontrolerów do Query Scopes i klas Action
- Soft deletes stosują globalny scope - zawsze zastanów się, czy potrzebujesz
withTrashed() - Wybierz właściwą metodę upsert:
firstOrNew,firstOrCreatelubupdateOrCreate- nie są wymienne - Używaj
chunk()lubchunkById()dla każdej operacji na więcej niż kilkuset rekordach Model::all()to nieModel::get()- naucz się różnicy zanim kosztuje Cię to na produkcji
Obserwuj mnie na LinkedIn po więcej tipów z Laravel! Trafiłeś na inną pułapkę Eloquent na produkcji? Podziel się nią w komentarzach!