8 Błędów Eloquent Które Po Cichu Niszczą Wydajność Twojej Aplikacji

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

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ścieKolumnyPamięć 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ń.

MetodaSzuka rekorduTworzy jeśli brakAktualizuje 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 przez withCount(), withSum() i inne metody agregujące
  • Dodaj indeksy do każdej kolumny używanej w where(), orderBy() i kluczach obcych - sprawdzaj przez EXPLAIN
  • 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, firstOrCreate lub updateOrCreate - nie są wymienne
  • Używaj chunk() lub chunkById() dla każdej operacji na więcej niż kilkuset rekordach
  • Model::all() to nie Model::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!

Komentarze (0)
Zostaw komentarz

© 2026 Wszelkie prawa zastrzeżone.