192 lines
4.1 KiB
Markdown
192 lines
4.1 KiB
Markdown
# 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):
|
|
```php
|
|
$posts = Post::all();
|
|
foreach ($posts as $post) {
|
|
echo $post->author->name;
|
|
}
|
|
```
|
|
|
|
Correct (2 queries total):
|
|
```php
|
|
$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):
|
|
|
|
```php
|
|
$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.
|
|
|
|
```php
|
|
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:
|
|
```php
|
|
$posts = Post::with('author')->get();
|
|
```
|
|
|
|
Correct:
|
|
```php
|
|
$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:
|
|
```php
|
|
$users = User::all();
|
|
foreach ($users as $user) {
|
|
$user->notify(new WeeklyDigest);
|
|
}
|
|
```
|
|
|
|
Correct:
|
|
```php
|
|
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:
|
|
|
|
```php
|
|
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:
|
|
```php
|
|
Schema::create('orders', function (Blueprint $table) {
|
|
$table->id();
|
|
$table->foreignId('user_id')->constrained();
|
|
$table->string('status');
|
|
$table->timestamps();
|
|
});
|
|
```
|
|
|
|
Correct:
|
|
```php
|
|
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:
|
|
```php
|
|
$posts = Post::all();
|
|
foreach ($posts as $post) {
|
|
echo $post->comments->count();
|
|
}
|
|
```
|
|
|
|
Correct:
|
|
```php
|
|
$posts = Post::withCount('comments')->get();
|
|
foreach ($posts as $post) {
|
|
echo $post->comments_count;
|
|
}
|
|
```
|
|
|
|
Conditional counting:
|
|
|
|
```php
|
|
$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:
|
|
```php
|
|
$users = User::where('active', true)->get();
|
|
```
|
|
|
|
Correct:
|
|
```php
|
|
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:
|
|
```blade
|
|
@foreach (User::all() as $user)
|
|
{{ $user->profile->name }}
|
|
@endforeach
|
|
```
|
|
|
|
Correct:
|
|
```php
|
|
// Controller
|
|
$users = User::with('profile')->get();
|
|
return view('users.index', compact('users'));
|
|
```
|
|
|
|
```blade
|
|
@foreach ($users as $user)
|
|
{{ $user->profile->name }}
|
|
@endforeach
|
|
``` |