Database Optimization & Performance
Introduction to Database Optimization
Database performance is crucial for application speed and scalability. As your application grows, inefficient database queries can become a major bottleneck. This lesson covers essential optimization techniques including solving the N+1 problem, eager loading, indexing, query optimization, caching, and proper use of database transactions.
Why Database Optimization Matters:
- Reduces page load time and improves user experience
- Lowers server resource consumption (CPU, memory, I/O)
- Allows application to handle more concurrent users
- Reduces database server costs and infrastructure needs
- Improves SEO through faster page speeds
- Prevents timeouts and errors under heavy load
The N+1 Query Problem
The N+1 problem occurs when you execute one query to fetch a collection, then execute N additional queries (one for each item) to fetch related data. This is one of the most common performance issues in Laravel applications.
Example of N+1 Problem:
<?php
// BAD: N+1 Problem - 101 queries for 100 posts
$posts = Post::all(); // 1 query
foreach ($posts as $post) {
echo $post->user->name; // 100 additional queries (1 per post)
}
Performance Impact: If you have 100 posts, this code executes 101 database queries (1 for posts + 100 for users). With 1000 posts, it becomes 1001 queries!
Solution: Eager Loading
<?php
// GOOD: Eager Loading - Only 2 queries
$posts = Post::with('user')->get(); // 2 queries total
foreach ($posts as $post) {
echo $post->user->name; // No additional queries
}
// Multiple relationships
$posts = Post::with(['user', 'comments', 'tags'])->get();
// Nested relationships
$posts = Post::with(['comments.user', 'user.profile'])->get();
// Conditional eager loading
$posts = Post::with([
'comments' => function ($query) {
$query->where('approved', true)->orderBy('created_at', 'desc');
}
])->get();
Lazy Eager Loading
Load relationships after the parent model has already been retrieved:
<?php
$posts = Post::all();
// Realize you need users later
$posts->load('user');
// Load multiple relationships
$posts->load(['user', 'comments']);
// Conditional lazy eager loading
if ($includeComments) {
$posts->load('comments');
}
Detecting N+1 Problems
<?php
// Install Laravel Debugbar
composer require barryvdh/laravel-debugbar --dev
// Or use query logging
\Illuminate\Support\Facades\DB::enableQueryLog();
// Your code here
$posts = Post::all();
foreach ($posts as $post) {
echo $post->user->name;
}
// Check executed queries
$queries = \Illuminate\Support\Facades\DB::getQueryLog();
dd($queries); // See all queries executed
Prevention: Enable preventLazyLoading() in development to catch N+1 problems early:
// app/Providers/AppServiceProvider.php
public function boot()
{
Model::preventLazyLoading(!app()->isProduction());
}
Database Indexing
Database indexes dramatically speed up data retrieval but slow down writes. Think of indexes like a book's index - they help you find information quickly without reading every page.
When to Add Indexes:
- Foreign keys (user_id, post_id, etc.)
- Columns used in WHERE clauses frequently
- Columns used in ORDER BY clauses
- Columns used in JOIN operations
- Unique constraints (email, username)
<?php
// Create migration with indexes
php artisan make:migration add_indexes_to_posts_table
// In migration file
public function up()
{
Schema::table('posts', function (Blueprint $table) {
// Index single column
$table->index('user_id');
$table->index('status');
$table->index('published_at');
// Composite index (multiple columns)
$table->index(['user_id', 'status']);
// Unique index
$table->unique('slug');
// Full-text index (for searching)
$table->fullText(['title', 'content']);
});
}
public function down()
{
Schema::table('posts', function (Blueprint $table) {
$table->dropIndex(['user_id']);
$table->dropIndex(['status']);
$table->dropIndex(['published_at']);
$table->dropIndex(['user_id', 'status']);
$table->dropUnique(['slug']);
$table->dropFullText(['title', 'content']);
});
}
<?php
// Use full-text search with index
$posts = Post::whereFullText(['title', 'content'], 'Laravel tutorial')
->get();
// Without full-text index (much slower)
$posts = Post::where('title', 'like', '%Laravel%')
->orWhere('content', 'like', '%Laravel%')
->get();
Index Trade-offs: While indexes speed up reads, they slow down INSERT/UPDATE/DELETE operations because the index must be updated too. Don't over-index - only add indexes for columns you actually query frequently.
Query Optimization
Select Only Needed Columns
<?php
// BAD: Fetches all columns
$users = User::all();
// GOOD: Fetch only needed columns
$users = User::select('id', 'name', 'email')->get();
// In relationships
$posts = Post::with(['user:id,name,email'])->get();
Use Chunking for Large Datasets
<?php
// BAD: Loads all records into memory at once
$users = User::all(); // May cause memory issues with 100k+ records
foreach ($users as $user) {
// Process user
}
// GOOD: Process in chunks
User::chunk(1000, function ($users) {
foreach ($users as $user) {
// Process user
}
});
// Even better: Use lazy collections for memory efficiency
User::lazy()->each(function ($user) {
// Process user one at a time
});
Count Efficiently
<?php
// BAD: Loads all records just to count
$count = Post::all()->count();
// GOOD: Database count (much faster)
$count = Post::count();
// Existence checks
if (Post::where('user_id', $userId)->exists()) {
// User has posts
}
// Instead of
if (Post::where('user_id', $userId)->count() > 0) {
// User has posts
}
Avoid SELECT N+1 with Aggregates
<?php
// BAD: N+1 for counting
$users = User::all();
foreach ($users as $user) {
echo $user->posts()->count(); // N queries
}
// GOOD: Use withCount()
$users = User::withCount('posts')->get();
foreach ($users as $user) {
echo $user->posts_count; // No additional queries
}
// Multiple counts
$users = User::withCount(['posts', 'comments', 'likes'])->get();
// Conditional count
$users = User::withCount([
'posts',
'posts as published_posts_count' => function ($query) {
$query->where('status', 'published');
}
])->get();
Query Caching
Cache expensive query results to avoid hitting the database repeatedly:
<?php
use Illuminate\Support\Facades\Cache;
// Cache query results for 1 hour
$posts = Cache::remember('popular_posts', 3600, function () {
return Post::where('views', '>', 1000)
->with(['user', 'comments'])
->orderBy('views', 'desc')
->limit(10)
->get();
});
// Cache with tags (for easier clearing)
$posts = Cache::tags(['posts'])->remember('popular_posts', 3600, function () {
return Post::popular()->get();
});
// Clear tagged cache
Cache::tags(['posts'])->flush();
// Cache forever (until manually cleared)
$stats = Cache::rememberForever('site_stats', function () {
return [
'total_users' => User::count(),
'total_posts' => Post::count(),
];
});
// Clear specific cache
Cache::forget('popular_posts');
Model Caching Pattern
<?php
// app/Models/Post.php
class Post extends Model
{
public static function popular()
{
return Cache::remember('posts:popular', 3600, function () {
return static::where('views', '>', 1000)
->orderBy('views', 'desc')
->limit(10)
->get();
});
}
// Clear cache when model is saved
protected static function booted()
{
static::saved(function () {
Cache::forget('posts:popular');
});
static::deleted(function () {
Cache::forget('posts:popular');
});
}
}
Database Transactions
Transactions ensure data integrity by grouping multiple database operations into a single atomic unit:
<?php
use Illuminate\Support\Facades\DB;
// Automatic transaction (recommended)
DB::transaction(function () {
$user = User::create(['name' => 'John', 'email' => 'john@example.com']);
$user->profile()->create(['bio' => 'Developer']);
$user->roles()->attach(1);
// If any operation fails, all will be rolled back
});
// Manual transaction
DB::beginTransaction();
try {
$order = Order::create([
'user_id' => $userId,
'total' => $total,
]);
foreach ($items as $item) {
$order->items()->create($item);
}
Inventory::decrement($item['product_id'], $item['quantity']);
DB::commit(); // Commit if everything succeeds
} catch (\Exception $e) {
DB::rollBack(); // Rollback on error
throw $e;
}
Transaction Best Practices:
- Keep transactions short - don't include external API calls
- Don't nest transactions unnecessarily
- Use transactions for operations that must succeed or fail together
- Transactions lock database rows - minimize lock time
Pagination Best Practices
<?php
// Standard pagination
$posts = Post::paginate(20); // 20 per page
// Simple pagination (faster, no total count)
$posts = Post::simplePaginate(20);
// Cursor pagination (best for large datasets)
$posts = Post::cursorPaginate(20);
// In controller
public function index()
{
$posts = Post::with('user')
->latest()
->paginate(20);
return view('posts.index', compact('posts'));
}
// In view
{{ $posts->links() }}
// Custom pagination view
{{ $posts->links('pagination.custom') }}
Query Scopes for Reusability
<?php
// app/Models/Post.php
class Post extends Model
{
// Local scope
public function scopePublished($query)
{
return $query->where('status', 'published')
->where('published_at', '<=', now());
}
public function scopePopular($query, $threshold = 1000)
{
return $query->where('views', '>=', $threshold);
}
public function scopeByAuthor($query, $userId)
{
return $query->where('user_id', $userId);
}
// Global scope (applied to all queries)
protected static function booted()
{
static::addGlobalScope('published', function ($query) {
$query->where('status', 'published');
});
}
}
// Usage
$posts = Post::published()->popular()->get();
$myPosts = Post::published()->byAuthor(auth()->id())->get();
// Remove global scope when needed
$allPosts = Post::withoutGlobalScope('published')->get();
Practice Exercise 1: Optimize Blog Posts Listing
Given this inefficient code, optimize it:
<?php
$posts = Post::all();
foreach ($posts as $post) {
echo $post->user->name;
echo $post->category->name;
echo $post->comments->count();
echo $post->tags->pluck('name')->implode(', ');
}
Requirements:
- Fix N+1 problems with eager loading
- Use withCount() for comment counts
- Add pagination (20 per page)
- Cache the results for 5 minutes
- Select only needed columns
Practice Exercise 2: Add Strategic Indexes
Create a migration to add indexes for this posts table:
- Add index for user_id (foreign key)
- Add index for status column
- Add composite index for (status, published_at)
- Add unique index for slug
- Add full-text index for title and content
Test: Write queries that benefit from these indexes and use EXPLAIN to verify index usage.
Practice Exercise 3: Transaction-based Order Processing
Implement a checkout process using database transactions:
- Create order record
- Create order items
- Decrease product inventory
- Record payment transaction
- Send order confirmation (outside transaction)
Requirements:
- All database operations must succeed or all fail
- Check inventory availability before processing
- Handle errors gracefully with rollback
- Log any transaction failures
Summary
In this lesson, you learned essential database optimization techniques:
- Identifying and solving N+1 query problems with eager loading
- Using database indexes to speed up queries
- Optimizing queries by selecting only needed columns
- Chunking and lazy collections for processing large datasets
- Using withCount() to avoid N+1 with aggregates
- Implementing query result caching with Laravel's Cache facade
- Using database transactions for data integrity
- Implementing efficient pagination strategies
- Creating reusable query scopes
- Best practices for database performance
Database optimization is crucial for building scalable applications. These techniques will significantly improve your application's performance and user experience. In the next lesson, we'll build a complete CRUD application putting all these concepts together.