4.1 KiB
Database Performance Best Practices
Always Eager Load Relationships
Lazy loading causes N+1 query problems — one query per loop iteration. Always use with() to load relationships upfront.
Incorrect (N+1 — executes 1 + N queries):
$posts = Post::all();
foreach ($posts as $post) {
echo $post->author->name;
}
Correct (2 queries total):
$posts = Post::with('author')->get();
foreach ($posts as $post) {
echo $post->author->name;
}
Constrain eager loads to select only needed columns (always include the foreign key):
$users = User::with(['posts' => function ($query) {
$query->select('id', 'user_id', 'title')
->where('published', true)
->latest()
->limit(10);
}])->get();
Prevent Lazy Loading in Development
Enable this in AppServiceProvider::boot() to catch N+1 issues during development.
public function boot(): void
{
Model::preventLazyLoading(! app()->isProduction());
}
Throws LazyLoadingViolationException when a relationship is accessed without being eager-loaded.
Select Only Needed Columns
Avoid SELECT * — especially when tables have large text or JSON columns.
Incorrect:
$posts = Post::with('author')->get();
Correct:
$posts = Post::select('id', 'title', 'user_id', 'created_at')
->with(['author:id,name,avatar'])
->get();
When selecting columns on eager-loaded relationships, always include the foreign key column or the relationship won't match.
Chunk Large Datasets
Never load thousands of records at once. Use chunking for batch processing.
Incorrect:
$users = User::all();
foreach ($users as $user) {
$user->notify(new WeeklyDigest);
}
Correct:
User::where('subscribed', true)->chunk(200, function ($users) {
foreach ($users as $user) {
$user->notify(new WeeklyDigest);
}
});
Use chunkById() when modifying records during iteration — standard chunk() uses OFFSET which shifts when rows change:
User::where('active', false)->chunkById(200, function ($users) {
$users->each->delete();
});
Add Database Indexes
Index columns that appear in WHERE, ORDER BY, JOIN, and GROUP BY clauses.
Incorrect:
Schema::create('orders', function (Blueprint $table) {
$table->id();
$table->foreignId('user_id')->constrained();
$table->string('status');
$table->timestamps();
});
Correct:
Schema::create('orders', function (Blueprint $table) {
$table->id();
$table->foreignId('user_id')->index()->constrained();
$table->string('status')->index();
$table->timestamps();
$table->index(['status', 'created_at']);
});
Add composite indexes for common query patterns (e.g., WHERE status = ? ORDER BY created_at).
Use withCount() for Counting Relations
Never load entire collections just to count them.
Incorrect:
$posts = Post::all();
foreach ($posts as $post) {
echo $post->comments->count();
}
Correct:
$posts = Post::withCount('comments')->get();
foreach ($posts as $post) {
echo $post->comments_count;
}
Conditional counting:
$posts = Post::withCount([
'comments',
'comments as approved_comments_count' => function ($query) {
$query->where('approved', true);
},
])->get();
Use cursor() for Memory-Efficient Iteration
For read-only iteration over large result sets, cursor() loads one record at a time via a PHP generator.
Incorrect:
$users = User::where('active', true)->get();
Correct:
foreach (User::where('active', true)->cursor() as $user) {
ProcessUser::dispatch($user->id);
}
Use cursor() for read-only iteration. Use chunk() / chunkById() when modifying records.
No Queries in Blade Templates
Never execute queries in Blade templates. Pass data from controllers.
Incorrect:
@foreach (User::all() as $user)
{{ $user->profile->name }}
@endforeach
Correct:
// Controller
$users = User::with('profile')->get();
return view('users.index', compact('users'));
@foreach ($users as $user)
{{ $user->profile->name }}
@endforeach