Laravel Multi-Level Eager Loading: Optimizing Nested Relationship Queries
The N+1 query problem becomes exponentially worse with nested relationships. Laravel's multi-level eager loading feature allows you to load multiple relationship levels in a single query, dramatically improving performance and reducing database load.
Understanding Multi-Level Eager Loading
Multi-level eager loading uses dot notation to specify nested relationships that should be loaded together:
// Load books with their authors and the authors' countries
$books = Book::with('author.country')->get();
This single query loads three levels of relationships efficiently, preventing multiple database queries for each nested relationship.
Basic Multi-Level Loading Patterns
Two-Level Relationships
// E-commerce: Load orders with customer and billing address
$orders = Order::with('customer.billingAddress')->get();
// Blog: Load posts with author profiles
$posts = Post::with('author.profile')->get();
// Social: Load comments with user avatars
$comments = Comment::with('user.avatar')->get();
Three-Level Relationships
// Deep e-commerce relationships
$products = Product::with('category.parent.image')->get();
// Complex user data
$users = User::with('profile.address.country')->get();
// Nested content relationships
$articles = Article::with('author.company.address')->get();
Advanced Eager Loading Techniques
Multiple Nested Relationships
Load several nested relationship chains simultaneously:
$posts = Post::with([
'author.profile.avatar',
'author.company.address.country',
'comments.user.profile',
'comments.replies.user',
'tags.category',
'featuredImage.variants'
])->get();
Conditional Nested Loading
Apply constraints to nested relationships:
$orders = Order::with([
'customer.profile',
'items.product.images',
'items.product.reviews' => function ($query) {
$query->where('rating', '>=', 4)
->latest()
->limit(5);
},
'shipping.address.country',
'payment.method'
])->get();
Performance-Optimized Loading
Select only necessary fields from nested relationships:
$users = User::with([
'profile:id,user_id,avatar,display_name',
'profile.address:id,profile_id,city,country',
'posts:id,user_id,title,slug,created_at',
'posts.comments:id,post_id,user_id,content,created_at',
'posts.comments.user:id,name,avatar'
])->get();
Real-World Implementation Examples
E-commerce Product Catalog
class ProductController extends Controller
{
public function index(Request $request)
{
$products = Product::with([
// Basic product relationships
'category.parent',
'brand.logo',
// Product variants and pricing
'variants.images',
'variants.inventory.warehouse',
// Reviews and ratings
'reviews' => function ($query) {
$query->approved()
->latest()
->limit(3)
->with('user:id,name,avatar');
},
// Related products
'relatedProducts' => function ($query) {
$query->active()
->limit(4)
->with('featuredImage', 'brand:id,name');
},
// SEO and metadata
'seoData.images'
])->active()->paginate(24);
return view('products.index', compact('products'));
}
public function show(Product $product)
{
$product->load([
// Complete product information
'category.parent.seoData',
'brand.description.translations',
// All variants with complete data
'variants.images.sizes',
'variants.attributes.values',
'variants.inventory.warehouse.address',
'variants.pricing.currency',
// Complete review system
'reviews.user.profile.avatar',
'reviews.replies.user:id,name,avatar',
'reviews.images.thumbnails',
// Recommendations
'recommendations.product.featuredImage',
'recommendations.product.brand:id,name,logo',
// Cross-sells and upsells
'crossSells.images:id,product_id,url,alt_text',
'upSells.category:id,name,slug'
]);
return view('products.show', compact('product'));
}
}
Social Media Feed
class FeedController extends Controller
{
public function timeline(Request $request)
{
$posts = Post::whereIn('user_id', auth()->user()->followingIds())
->with([
// Author information
'author.profile.avatar',
'author.verificationBadge',
// Post content
'media.thumbnails',
'media.metadata',
'location.coordinates',
// Engagement data
'likes' => function ($query) {
$query->limit(3)->with('user:id,name,avatar');
},
'comments' => function ($query) {
$query->latest()
->limit(5)
->with([
'user.profile.avatar',
'likes.user:id,name',
'replies' => function ($subQuery) {
$subQuery->limit(2)
->with('user:id,name,avatar');
}
]);
},
// Content relationships
'hashtags.trending',
'mentions.user.profile',
'shares.user:id,name,avatar'
])
->latest()
->paginate(20);
return view('feed.timeline', compact('posts'));
}
}
Enterprise CRM Dashboard
class DashboardController extends Controller
{
public function analytics()
{
$customers = Customer::with([
// Customer details
'profile.avatar',
'company.industry.sector',
'addresses.country.timezone',
// Account management
'accountManager.profile.contact',
'accountManager.team.department',
// Transaction history
'orders.items.product.category',
'orders.payments.method.processor',
'orders.shipping.carrier.tracking',
// Support interactions
'tickets.assignee.department',
'tickets.messages.attachments',
'tickets.resolutions.category',
// Marketing engagement
'campaignInteractions.campaign.segment',
'emailSubscriptions.list.category',
'websiteActivity.pages.analytics'
])
->whereHas('orders', function ($query) {
$query->where('created_at', '>=', now()->subYear());
})
->get();
return view('dashboard.analytics', compact('customers'));
}
}
Performance Optimization Strategies
Memory-Conscious Loading
class OptimizedQueries
{
public function loadLargeDataset()
{
// Use chunking for large datasets with nested relationships
User::with([
'profile.settings',
'orders.items.product:id,name,price',
'notifications.type:id,name'
])
->chunk(100, function ($users) {
foreach ($users as $user) {
$this->processUser($user);
}
});
}
public function selectiveLoading()
{
// Load only necessary fields to reduce memory usage
return Order::with([
'customer:id,name,email,phone',
'customer.addresses:id,customer_id,type,street,city',
'items:id,order_id,product_id,quantity,price',
'items.product:id,name,sku,image_url',
'shipping:id,order_id,carrier,tracking_number,status'
])
->select('id', 'customer_id', 'total', 'status', 'created_at')
->get();
}
}
Query Count Monitoring
class QueryMonitor
{
public function monitorEagerLoading()
{
DB::enableQueryLog();
$posts = Post::with([
'author.profile.avatar',
'comments.user.profile',
'tags.category'
])->get();
$queryCount = count(DB::getQueryLog());
// Should be around 4 queries regardless of result count:
// 1. Posts
// 2. Authors + Profiles + Avatars
// 3. Comments + Users + Profiles
// 4. Tags + Categories
if ($queryCount > 10) {
Log::warning('Too many queries detected', [
'query_count' => $queryCount,
'queries' => DB::getQueryLog()
]);
}
return $posts;
}
}
Caching Nested Relationships
class CachedRelationships
{
public function getCachedPosts()
{
return Cache::tags(['posts', 'authors', 'comments'])
->remember('posts.with.nested', 3600, function () {
return Post::with([
'author.profile.settings',
'comments.replies.user',
'tags.translations'
])->get();
});
}
public function invalidateCache()
{
Cache::tags(['posts', 'authors', 'comments'])->flush();
}
}
Testing Multi-Level Eager Loading
Query Count Assertions
class EagerLoadingTest extends TestCase
{
public function test_posts_load_with_minimal_queries()
{
$posts = factory(Post::class, 10)
->create()
->each(function ($post) {
$post->comments()->saveMany(
factory(Comment::class, 3)->make()
);
});
DB::enableQueryLog();
$loadedPosts = Post::with([
'author.profile',
'comments.user.avatar'
])->get();
// Should execute exactly 4 queries regardless of post count
$this->assertCount(4, DB::getQueryLog());
// Verify relationships are loaded
$loadedPosts->each(function ($post) {
$this->assertTrue($post->relationLoaded('author'));
$this->assertTrue($post->author->relationLoaded('profile'));
$post->comments->each(function ($comment) {
$this->assertTrue($comment->relationLoaded('user'));
$this->assertTrue($comment->user->relationLoaded('avatar'));
});
});
}
}
Performance Benchmarks
class PerformanceTest extends TestCase
{
public function test_eager_loading_vs_lazy_loading_performance()
{
$posts = factory(Post::class, 50)->create();
// Measure lazy loading
$start = microtime(true);
DB::enableQueryLog();
$lazyPosts = Post::all();
foreach ($lazyPosts as $post) {
$post->author->profile->avatar; // Triggers N+1
}
$lazyTime = microtime(true) - $start;
$lazyQueries = count(DB::getQueryLog());
// Reset query log
DB::flushQueryLog();
// Measure eager loading
$start = microtime(true);
$eagerPosts = Post::with('author.profile.avatar')->get();
foreach ($eagerPosts as $post) {
$post->author->profile->avatar;
}
$eagerTime = microtime(true) - $start;
$eagerQueries = count(DB::getQueryLog());
// Eager loading should be faster and use fewer queries
$this->assertLessThan($lazyTime, $eagerTime);
$this->assertLessThan($lazyQueries, $eagerQueries);
$this->assertLessThan(10, $eagerQueries); // Should be around 4 queries
}
}
Common Pitfalls and Solutions
Over-eager Loading
// ❌ Bad: Loading unnecessary relationships
$users = User::with([
'profile.settings.preferences.theme.colors',
'orders.items.product.variants.images.sizes.metadata',
'notifications.type.templates.translations'
])->get();
// ✅ Good: Load only what you need
$users = User::with([
'profile:id,user_id,display_name,avatar',
'orders' => function ($query) {
$query->recent()->with('items.product:id,name,price');
}
])->get();
Duplicate Relationship Loading
// ❌ Bad: Redundant loading
$posts = Post::with([
'author',
'author.profile',
'author.profile.settings'
])->get();
// ✅ Good: Consolidated loading
$posts = Post::with('author.profile.settings')->get();
Best Practices
1. Plan Your Loading Strategy
// Document complex loading strategies
class PostRepository
{
/**
* Load posts with complete author and engagement data
*
* Relationships loaded:
* - author.profile.avatar (3 levels)
* - comments.replies.user (3 levels)
* - tags.category (2 levels)
*
* Expected queries: ~4-5 regardless of result count
*/
public function getPostsWithEngagement()
{
return Post::with([
'author.profile.avatar',
'comments.replies.user:id,name,avatar',
'tags.category:id,name,color'
])->published()->get();
}
}
2. Monitor and Profile
class DatabaseProfiler
{
public function profileQuery(callable $callback)
{
$start = microtime(true);
DB::enableQueryLog();
$result = $callback();
$time = (microtime(true) - $start) * 1000;
$queries = DB::getQueryLog();
Log::info('Query Profile', [
'time_ms' => round($time, 2),
'query_count' => count($queries),
'queries' => $queries
]);
return $result;
}
}
3. Use Lazy Collections for Large Datasets
// For very large datasets, use lazy collections
$posts = Post::with([
'author.profile',
'comments.user'
])->lazy()->each(function ($post) {
$this->processPost($post);
});
Conclusion
Multi-level eager loading is essential for building performant Laravel applications with complex data relationships. By mastering these techniques, you can:
- Eliminate N+1 query problems in nested relationships
- Optimize database performance through strategic loading
- Reduce memory usage with selective field loading
- Improve user experience with faster page loads
Remember to always profile your queries, load only necessary data, and consider caching strategies for frequently accessed nested relationships. Proper implementation of multi-level eager loading can transform a slow application into a performant, scalable system.