- Eager Loading (N+1 Problem vermeiden)
- Query Optimierung
- Subqueries
- Joins
- Raw Queries & Expressions
- Caching
- Indexierung & Performance
- Spezielle Optimierungen
- Relationship Optimierungen
- Debugging & Monitoring
Das N+1 Problem ist eines der häufigsten Performance-Probleme in Laravel-Anwendungen. Es tritt auf, wenn Sie eine Liste von Modellen laden (1 Query) und dann für jedes Modell eine Beziehung abfragen (N Queries), was zu N+1 Datenbankabfragen führt.
Beispiel des Problems:
// Schlecht: Erzeugt N+1 Queries
$users = User::all(); // Query 1: SELECT * FROM users
foreach ($users as $user) {
// Query 2 bis N+1: SELECT * FROM posts WHERE user_id = ?
echo $user->posts->count();
}Bei 100 Usern würden hier 101 Queries ausgeführt!
Mit with() laden Sie Beziehungen im Voraus mit nur einer zusätzlichen Query:
// Gut: Nur 2 Queries insgesamt
$users = User::with('posts')->get();
// Query 1: SELECT * FROM users
// Query 2: SELECT * FROM posts WHERE user_id IN (1, 2, 3, ...)
foreach ($users as $user) {
echo $user->posts->count(); // Keine zusätzliche Query!
}Mehrere Beziehungen laden:
// Lädt posts und comments mit 3 Queries total
$users = User::with(['posts', 'comments'])->get();Verschachtelte Beziehungen:
// Lädt alle Posts und deren Comments
$users = User::with('posts.comments')->get();
// Query 1: SELECT * FROM users
// Query 2: SELECT * FROM posts WHERE user_id IN (...)
// Query 3: SELECT * FROM comments WHERE post_id IN (...)Bedingte Eager Loading:
// Lädt nur published Posts
$users = User::with(['posts' => function ($query) {
$query->where('published', true)
->orderBy('created_at', 'desc');
}])->get();Wenn Sie nur die Anzahl der Beziehungen benötigen, ist withCount() effizienter als das Laden aller Daten:
// Zählt Posts ohne sie zu laden
$users = User::withCount('posts')->get();
foreach ($users as $user) {
echo $user->posts_count; // Automatisch verfügbar
}
// Mit Bedingungen
$users = User::withCount(['posts' => function ($query) {
$query->where('published', true);
}])->get();
// $user->posts_count enthält nur published postsManchmal möchten Sie Beziehungen erst nach dem initialen Laden hinzufügen:
$users = User::all();
// Entscheidung basierend auf Logik
if ($needPosts) {
$users->load('posts');
}
// Oder bedingt für einzelne Modelle
$user = User::find(1);
if ($user->is_premium) {
$user->load('premiumContent');
}Standardmäßig lädt Laravel alle Spalten (SELECT *). Mit select() können Sie die geladenen Daten minimieren:
// Statt SELECT * FROM users
User::all();
// Besser: SELECT id, name, email FROM users
User::select('id', 'name', 'email')->get();
// Bei Beziehungen wichtig: Foreign Keys einschließen!
User::select('id', 'name')->with(['posts' => function ($query) {
// user_id wird für die Beziehung benötigt!
$query->select('id', 'user_id', 'title');
}])->get();Bei der Verarbeitung großer Datenmengen kann der Speicher schnell voll werden. chunk() verarbeitet Daten in kleineren Blöcken:
// Verarbeitet 100 User auf einmal
User::chunk(100, function ($users) {
foreach ($users as $user) {
// Verarbeitung - VORSICHT bei Updates!
$user->sendNewsletter();
}
});
// WICHTIG: chunk() hat Probleme wenn WHERE-Bedingungen geändert werden
User::where('active', true)
->chunk(100, function ($users) {
foreach ($users as $user) {
// PROBLEM: Ändert die WHERE-Bedingung!
$user->update(['active' => false]); // Kann User überspringen!
}
});
// LÖSUNG: chunkById() verwenden bei Updates
User::where('active', true)
->chunkById(100, function ($users) {
foreach ($users as $user) {
// SICHER: Nutzt ID-basiertes Chunking
$user->update(['active' => false]);
}
}, 'id');Warum chunkById()?
chunk()nutzt LIMIT und OFFSET- Bei Updates kann sich die Reihenfolge ändern
chunkById()nutzt WHERE id > ? statt OFFSET- Dadurch werden keine Datensätze übersprungen
cursor() verwendet PHP Generatoren und hält nur einen Datensatz im Speicher:
// Sehr speichereffizient
foreach (User::cursor() as $user) {
// Nur ein User im Speicher
processUser($user);
}
// Mit where Bedingungen
foreach (User::where('active', true)->cursor() as $user) {
// Verarbeitung
}Vorteil: Minimal Memory-Verbrauch
Nachteil: Hält Datenbankverbindung länger offen
Mit addSelect() können Sie berechnete Werte als Spalten hinzufügen:
$users = User::addSelect([
'last_post_title' => Post::select('title')
->whereColumn('user_id', 'users.id')
->latest()
->limit(1)
])->get();
// Jeder User hat jetzt $user->last_post_title
foreach ($users as $user) {
echo $user->name . ': ' . $user->last_post_title;
}Wie es funktioniert:
- Subquery wird für jede Zeile ausgeführt
whereColumn()verknüpft die Tabellenlimit(1)stellt sicher, dass nur ein Wert zurückgegeben wird
Für komplexere Subqueries ist selectSub() übersichtlicher:
// Definiere die Subquery
$latestPostDate = Post::select('created_at')
->whereColumn('user_id', 'users.id')
->latest()
->limit(1);
// Verwende sie mit einem Alias
$users = User::select('*')
->selectSub($latestPostDate, 'latest_post_date')
->get();
// Noch komplexeres Beispiel
$totalRevenue = Order::select(DB::raw('SUM(total)'))
->whereColumn('user_id', 'users.id')
->where('status', 'completed');
$users = User::select('*')
->selectSub($totalRevenue, 'total_revenue')
->get();whereHas() kann Performance-Probleme verursachen, ist aber für komplexe Bedingungen oft unverzichtbar:
// Standard whereHas() - flexible aber langsamer
$users = User::whereHas('posts', function ($query) {
$query->where('published', true)
->where('views', '>', 1000)
->where('created_at', '>', now()->subDays(30));
})->get();
// Optimiert mit Subquery - nur für einfache EXISTS
$users = User::whereIn('id', function ($query) {
$query->select('user_id')
->from('posts')
->where('published', true)
->groupBy('user_id');
})->get();
// Kombination für komplexe Fälle
$users = User::whereIn('id', function ($query) {
$query->select('user_id')
->from('posts')
->where('published', true)
->groupBy('user_id');
})
->whereHas('posts', function ($query) {
$query->where('views', '>', 1000);
})
->get();Wann welche Methode?
whereHas(): Komplexe Bedingungen, Relationen mit Constraints- Subquery: Einfache EXISTS-Prüfungen, bessere Performance
- Kombination: Erst grobe Filterung (Subquery), dann Feinfilterung (whereHas)
Joins sind oft schneller als Eager Loading für einfache Abfragen, aber Vorsicht vor Spaltennamenkonflikten!
// PROBLEM: Spaltennamenkonflikte bei select('*')
$users = User::join('posts', 'users.id', '=', 'posts.user_id')
->select('*') // GEFÄHRLICH: id, created_at, etc. überschreiben sich!
->get();
// BESSER: Explizite Spaltenauswahl
$users = User::join('posts', 'users.id', '=', 'posts.user_id')
->select([
'users.id as user_id',
'users.name',
'users.email',
'posts.id as post_id',
'posts.title',
'posts.created_at as post_created_at'
])
->get();
// ALTERNATIVE: Nur eine Tabelle komplett
$users = User::join('posts', 'users.id', '=', 'posts.user_id')
->select('users.*', 'posts.title as post_title')
->get();
// Bei mehreren Posts pro User: Duplikate vermeiden
$users = User::join('posts', 'users.id', '=', 'posts.user_id')
->select('users.*')
->distinct()
->get();Wichtige Hinweise:
idSpalten überschreiben sich beiselect('*')- Timestamps (created_at, updated_at) können kollidieren
- Verwenden Sie Aliase für eindeutige Spaltennamen
distinct()odergroupBy()gegen Duplikate
Left Join behält alle Datensätze der linken Tabelle, auch ohne Match:
// Alle User, auch ohne Posts
$users = User::leftJoin('posts', 'users.id', '=', 'posts.user_id')
->select('users.*', DB::raw('COUNT(posts.id) as post_count'))
->groupBy('users.id')
->get();
// Komplexeres Beispiel mit mehreren Joins
$data = User::leftJoin('profiles', 'users.id', '=', 'profiles.user_id')
->leftJoin('subscriptions', 'users.id', '=', 'subscriptions.user_id')
->select(
'users.*',
'profiles.bio',
'subscriptions.plan'
)
->get();Für komplexe SQL-Funktionen oder Berechnungen:
// Concatenation
$users = User::select(
DB::raw('CONCAT(first_name, " ", last_name) as full_name'),
'email'
)->get();
// Datums-Berechnungen
$orders = Order::select(
'*',
DB::raw('DATEDIFF(NOW(), created_at) as days_old')
)->get();
// Conditional Expressions
$users = User::select(
'*',
DB::raw('CASE
WHEN age >= 18 THEN "Adult"
ELSE "Minor"
END as age_group')
)->get();Direktere Syntax für Raw-Queries:
// Aggregation
$stats = User::selectRaw('
COUNT(*) as total_users,
AVG(age) as average_age,
MAX(created_at) as newest_user
')->first();
// Where conditions
$users = User::whereRaw('YEAR(created_at) = ?', [2024])
->whereRaw('age > ? AND status = ?', [18, 'active'])
->get();
// Having Raw
$usersByCountry = User::selectRaw('country, COUNT(*) as user_count')
->groupBy('country')
->havingRaw('COUNT(*) > ?', [100])
->get();Wichtig: Raw Queries umgehen Laravel's SQL Injection Schutz teilweise. Verwenden Sie immer Parameter-Binding (?) statt String-Konkatenation!
Mehrere Optionen:
// OPTION 1: Standard Cache Facade
$users = Cache::remember('all-users', 60, function () {
return User::all();
});
// Mit dynamischem Key
$activeUsers = Cache::remember('users-active-' . $status, 60, function () use ($status) {
return User::where('status', $status)->get();
});
// OPTION 2: Query Cache Package installieren
// composer require watson/rememberable
// Nach Installation:
use Watson\Rememberable\Rememberable;
class User extends Model
{
use Rememberable;
}
// Dann funktioniert:
$users = User::remember(60)->get();
$users = User::where('active', true)->remember(60, 'active-users')->get();
// OPTION 3: Eigenes Cache Trait
trait QueryCacheable
{
public function scopeCacheFor($query, $minutes, $key = null)
{
$key = $key ?? md5($query->toSql() . serialize($query->getBindings()));
return Cache::remember($key, $minutes * 60, function () use ($query) {
return $query->get();
});
}
}
// Verwendung:
class User extends Model
{
use QueryCacheable;
}
$users = User::where('active', true)->cacheFor(60);Cache Invalidierung:
// Manuell
Cache::forget('all-users');
// Mit Tags (Redis/Memcached)
Cache::tags(['users'])->flush();
// Event-basiert
class User extends Model
{
protected static function booted()
{
static::saved(function () {
Cache::forget('all-users');
Cache::tags(['users'])->flush();
});
}
}Für komplexere Caching-Logik:
// Standard caching
$users = Cache::remember('all-users', 3600, function () {
return User::with('posts')->get();
});
// Cache forever
$settings = Cache::rememberForever('app-settings', function () {
return Setting::all();
});
// Conditional caching
$users = Cache::when($shouldCache, function ($cache) {
return $cache->remember('users', 3600, function () {
return User::all();
});
}, function () {
return User::all();
});
// Cache mit Tags und custom Store
$users = Cache::store('redis')
->tags(['users', 'premium'])
->remember('premium-users', 3600, function () {
return User::where('is_premium', true)->get();
});Cache Invalidierung:
// Specific key
Cache::forget('all-users');
// Mit Tags
Cache::tags(['users'])->flush();
// Alles löschen
Cache::flush();Composite (mehrspaltige) Indexes sind wichtig für Queries mit mehreren WHERE-Bedingungen:
// Migration
Schema::table('posts', function (Blueprint $table) {
// Index für WHERE user_id = ? AND created_at > ?
$table->index(['user_id', 'created_at']);
// Unique composite index
$table->unique(['user_id', 'slug']);
});
// Query die diesen Index nutzt
Post::where('user_id', 1)
->where('created_at', '>', now()->subDays(30))
->get();Wichtig: Die Reihenfolge der Spalten im Index ist entscheidend! Der Index (user_id, created_at) hilft bei:
- WHERE user_id = ?
- WHERE user_id = ? AND created_at > ?
Aber NICHT bei:
- WHERE created_at > ? (ohne user_id)
Ein Covering Index enthält alle Spalten, die in der Query benötigt werden:
// Wenn Sie oft diese Query ausführen:
User::select('email', 'name', 'created_at')
->where('status', 'active')
->orderBy('created_at')
->get();
// Erstellen Sie einen covering index:
Schema::table('users', function (Blueprint $table) {
$table->index(['status', 'created_at', 'email', 'name']);
});Die Datenbank kann die Query komplett aus dem Index beantworten, ohne die Tabelle zu lesen!
Wenn Sie nur wissen müssen, ob Datensätze existieren:
// Ineffizient - zählt alle Datensätze
if (User::where('email', $email)->count() > 0) {
// ...
}
// Effizient - stoppt nach dem ersten Fund
if (User::where('email', $email)->exists()) {
// ...
}
// Negation
if (User::where('email', $email)->doesntExist()) {
// ...
}find() ist optimiert für Primary Key Lookups:
// Optimiert für Primary Key
$user = User::find(1);
// SQL: SELECT * FROM users WHERE id = 1 LIMIT 1
// Mit mehreren IDs
$users = User::find([1, 2, 3]);
// SQL: SELECT * FROM users WHERE id IN (1, 2, 3)
// first() für andere Bedingungen
$user = User::where('email', $email)->first();
// SQL: SELECT * FROM users WHERE email = ? LIMIT 1
// firstOrFail() wirft Exception
$user = User::where('email', $email)->firstOrFail();Atomare Operation zum Erstellen oder Aktualisieren:
// Atomic upsert
$user = User::updateOrCreate(
// Attributes zum Finden
['email' => $email],
// Attributes zum Updaten/Erstellen
['name' => $name, 'active' => true]
);
// Bulk upsert (Laravel 8+)
User::upsert([
['email' => '[email protected]', 'name' => 'User 1'],
['email' => '[email protected]', 'name' => 'User 2'],
], ['email'], ['name']);
// Zweites Array: Unique keys
// Drittes Array: Zu aktualisierende SpaltenDie Performance hängt von der Abfragerichtung ab, nicht pauschal von der Beziehungsart:
// BelongsTo - Foreign Key ist auf diesem Model
class Post extends Model
{
public function user()
{
return $this->belongsTo(User::class);
// posts.user_id -> users.id
}
}
// HasOne - Foreign Key ist auf dem anderen Model
class User extends Model
{
public function profile()
{
return $this->hasOne(Profile::class);
// users.id -> profiles.user_id
}
}Performance-Analyse:
// SZENARIO 1: Von Post zu User (BelongsTo)
$post = Post::find(1);
$user = $post->user; // SELECT * FROM users WHERE id = ?
// SEHR SCHNELL: Direkter Primary Key Lookup
// SZENARIO 2: Von User zu Profile (HasOne)
$user = User::find(1);
$profile = $user->profile; // SELECT * FROM profiles WHERE user_id = ? LIMIT 1
// SCHNELL: Mit Index auf user_id
// SZENARIO 3: Eager Loading mit BelongsTo
$posts = Post::with('user')->get();
// Query 1: SELECT * FROM posts
// Query 2: SELECT * FROM users WHERE id IN (?, ?, ?)
// EFFIZIENT: IN-Clause auf Primary Key
// SZENARIO 4: Eager Loading mit HasOne
$users = User::with('profile')->get();
// Query 1: SELECT * FROM users
// Query 2: SELECT * FROM profiles WHERE user_id IN (?, ?, ?)
// EFFIZIENT: IN-Clause auf indexed Foreign KeyBest Practices:
- BelongsTo: Optimal für "Kind zu Eltern" Beziehungen
- HasOne: Optimal für "Eltern zu Kind" Beziehungen
- Beide sind performant mit richtigen Indexes
- Wählen Sie basierend auf Domänen-Logik, nicht Performance
Laravel bietet mehrere Wege zur Optimierung polymorpher Beziehungen:
// Polymorphe Beziehung
class Activity extends Model
{
public function subject()
{
return $this->morphTo();
}
}
// STANDARD: Lädt alle möglichen Typen
$activities = Activity::with('subject')->get();
// OPTIMIERT: Mit constrain() für spezifische Eager Loading
$activities = Activity::with([
'subject' => function (MorphTo $morphTo) {
$morphTo->constrain([
Post::class => function ($query) {
$query->with(['user', 'comments']);
},
Comment::class => function ($query) {
$query->with(['user', 'post']);
},
Photo::class => function ($query) {
$query->with('album');
}
]);
}
])->get();
// ALTERNATIVE: morphWith() für einfachere Syntax
$activities = Activity::with([
'subject' => function (MorphTo $morphTo) {
$morphTo->morphWith([
Post::class => ['user', 'comments'],
Comment::class => ['user', 'post'],
Photo::class => ['album']
]);
}
])->get();
// FÜR COUNTS: morphWithCount()
$activities = Activity::withCount([
'subject' => function (MorphTo $morphTo) {
$morphTo->morphWithCount([
Post::class => ['comments'],
Video::class => ['views']
]);
}
])->get();Performance-Tipps:
- Nutzen Sie
constrain()für komplexe Bedingungen morphWith()für einfaches Eager LoadingmorphWithCount()wenn nur Counts benötigt werden- Definieren Sie nur die Typen, die Sie wirklich brauchen
Überwachen Sie alle Datenbankabfragen:
// In AppServiceProvider::boot()
DB::listen(function ($query) {
Log::info('Query', [
'sql' => $query->sql,
'bindings' => $query->bindings,
'time' => $query->time . 'ms'
]);
});
// Detaillierteres Logging
DB::listen(function ($query) {
if ($query->time > 100) { // Queries über 100ms
Log::warning('Slow query detected', [
'sql' => $query->sql,
'bindings' => $query->bindings,
'time' => $query->time . 'ms',
'connection' => $query->connectionName
]);
}
});Für temporäres Debugging:
// Query Log aktivieren
DB::enableQueryLog();
// Ihre Queries ausführen
$users = User::with('posts')->get();
// Log anzeigen
$queries = DB::getQueryLog();
dd($queries);
// Formatierter Output
foreach (DB::getQueryLog() as $query) {
$sql = str_replace('?', '%s', $query['query']);
$fullQuery = vsprintf($sql, $query['bindings']);
echo $fullQuery . ' (' . $query['time'] . 'ms)' . PHP_EOL;
}Telescope ist Laravel's offizielles Debug-Tool:
// Installation
composer require laravel/telescope --dev
php artisan telescope:install
php artisan migrate
// In AppServiceProvider registrieren
public function register()
{
if ($this->app->environment('local')) {
$this->app->register(\Laravel\Telescope\TelescopeServiceProvider::class);
}
}
// Falls Sie einen eigenen TelescopeServiceProvider erstellt haben:
// app/Providers/TelescopeServiceProvider.php
// Dann zusätzlich registrieren:
// $this->app->register(\App\Providers\TelescopeServiceProvider::class);Telescope zeigt:
- Alle Queries mit Execution Time
- Slow Queries
- Model Events
- Cache Hits/Misses
- Und vieles mehr
- Verwenden Sie Eager Loading (
with()) für Beziehungen - Nutzen Sie
withCount()statt Count-Queries in Loops - Batch Operations verwenden (
insert(),update())
- Nur benötigte Felder mit
select()laden - Pagination statt
all()für große Datensätze chunk()odercursor()für Batch-Verarbeitung
- Indexes auf häufig verwendete WHERE-Spalten
- Composite Indexes für kombinierte Bedingungen
- Covering Indexes für häufige Queries
- Cache teure/häufige Queries
- Cache-Tags für einfache Invalidierung
- Cache-Warming für kritische Daten
- Laravel Telescope in Entwicklung
- Query Logging in Produktion
- Regular Performance Audits
- Denormalisierung für Read-Heavy Tabellen
- Materialized Views für komplexe Aggregationen
- Read-Replicas für Skalierung
-
Keine Queries in Loops
// Schlecht foreach ($users as $user) { $posts = Post::where('user_id', $user->id)->get(); } // Gut $users = User::with('posts')->get();
-
Kein SELECT * bei großen Tabellen
// Schlecht $users = User::all(); // Gut $users = User::select('id', 'name', 'email')->get();
-
Keine ungenutzten Eager Loads
// Schlecht $users = User::with(['posts', 'comments', 'profile'])->get(); // Wenn Sie nur posts verwenden // Gut $users = User::with('posts')->get();
- Laravel Dokumentation - Eloquent Performance
- Laracasts - Eloquent Performance Patterns
- Laravel Debugbar
- Laravel Telescope
- MySQL Index Dokumentation
Dieses Dokument basiert auf Laravel Best Practices und Erfahrungen aus produktiven Anwendungen. Aktualisiert für Laravel 10.x/11.x.