Eager loading is a crucial performance optimization technique in Holloway that allows you to load related entities in a single query, avoiding the N+1 query problem that can severely impact application performance.
- Understanding the N+1 Problem
- Basic Eager Loading
- Nested Eager Loading
- Conditional Eager Loading
- Custom Eager Loading
- Performance Considerations
- Best Practices
The N+1 query problem occurs when you load a collection of entities and then access a relationship on each entity individually:
// This creates the N+1 problem
$posts = $postMapper->all();
foreach ($posts as $post) {
echo $post->author->name; // Each iteration triggers a separate query
}
// Result: 1 query for posts + N queries for authors = N+1 queriesWithout eager loading, this would execute:
- One query to fetch all posts
- One additional query for each post to fetch its author
Use the with() method to eager load relationships:
// Load posts with their authors in a single optimized query set
$posts = $postMapper->with('author')->all();
foreach ($posts as $post) {
echo $post->author->name; // No additional queries!
}Load multiple relationships simultaneously:
$posts = $postMapper
->with(['author', 'category', 'tags'])
->all();
foreach ($posts as $post) {
echo $post->author->name;
echo $post->category->title;
foreach ($post->tags as $tag) {
echo $tag->name;
}
}Apply constraints to eager loaded relationships:
// Only load published posts with their active comments
$posts = $postMapper
->with(['comments' => function($query) {
$query->where('status', 'approved')
->orderBy('created_at', 'desc');
}])
->where('status', 'published')
->get();Load relationships of relationships using dot notation:
// Load posts with authors and their profiles
$posts = $postMapper
->with('author.profile')
->all();
foreach ($posts as $post) {
echo $post->author->profile->bio;
}// Load posts with multiple nested relationships
$posts = $postMapper
->with([
'author.profile',
'author.company',
'category.parent',
'comments.author',
'tags.category'
])
->all();Apply constraints at different nesting levels:
$posts = $postMapper
->with([
'comments' => function($query) {
$query->where('status', 'approved')
->with(['author' => function($authorQuery) {
$authorQuery->where('is_verified', true);
}]);
}
])
->get();Use when() to conditionally eager load based on runtime conditions:
$includeComments = request('include_comments', false);
$posts = $postMapper
->when($includeComments, function($query) {
$query->with('comments.author');
})
->all();$posts = $postMapper
->with('author')
->when($user->canViewPrivateData(), function($query) {
$query->with(['author.email', 'author.phone']);
})
->all();Define custom eager loading logic in your mappers:
class PostMapper extends Mapper
{
public function withPopularComments()
{
return $this->with(['comments' => function($query) {
$query->where('likes_count', '>', 10)
->orderBy('likes_count', 'desc')
->limit(5);
}]);
}
public function withRecentActivity()
{
return $this->with([
'comments' => function($query) {
$query->where('created_at', '>', now()->subDays(7));
},
'likes' => function($query) {
$query->where('created_at', '>', now()->subDays(7));
}
]);
}
}
// Usage
$posts = $postMapper->withPopularComments()->get();
$activePosts = $postMapper->withRecentActivity()->get();Only load what you need:
// Good: Specific fields
$posts = $postMapper
->with(['author' => function($query) {
$query->select(['id', 'name', 'email']);
}])
->get();
// Avoid: Loading all fields when you only need a few
$posts = $postMapper->with('author')->get();Use limits to prevent loading too much data:
$posts = $postMapper
->with(['comments' => function($query) {
$query->latest()->limit(5);
}])
->get();For large datasets, consider using chunks with eager loading:
$postMapper
->with(['author', 'category'])
->chunk(100, function($posts) {
foreach ($posts as $post) {
// Process each post with its loaded relationships
processPost($post);
}
});Load relationships after the initial query when needed:
$posts = $postMapper->all();
// Later, load relationships when needed
$posts->load('author');
$posts->load(['comments', 'tags']);
// With constraints
$posts->load(['comments' => function($query) {
$query->where('status', 'approved');
}]);$posts = $postMapper->all();
if ($needsComments) {
$posts->load('comments.author');
}Check if relationships exist without loading them:
// Posts that have comments
$postsWithComments = $postMapper
->has('comments')
->get();
// Posts with at least 5 approved comments
$popularPosts = $postMapper
->has('comments', '>=', 5, function($query) {
$query->where('status', 'approved');
})
->get();// Load posts with comment counts
$posts = $postMapper
->withCount('comments')
->get();
foreach ($posts as $post) {
echo "Post has {$post->comments_count} comments";
}
// With constraints
$posts = $postMapper
->withCount(['comments' => function($query) {
$query->where('status', 'approved');
}])
->get();Always monitor query counts and execution time:
// Enable query logging in development
DB::enableQueryLog();
$posts = $postMapper->with('author.profile')->get();
// Check executed queries
$queries = DB::getQueryLog();
echo "Executed " . count($queries) . " queries";// Good: Load relationships you know you'll use
$posts = $postMapper
->with(['author', 'category'])
->paginate(20);
// Avoid: Loading relationships you might not use
$posts = $postMapper
->with(['author', 'category', 'comments', 'tags', 'likes'])
->paginate(20);// Define efficient relationship loading in your mappers
class PostMapper extends Mapper
{
public function withEssentials()
{
return $this->with([
'author:id,name,avatar',
'category:id,name,slug'
]);
}
public function withEngagement()
{
return $this->withCount(['comments', 'likes', 'shares']);
}
}Ensure your database has proper indexes for relationship queries:
-- For HasMany relationships
CREATE INDEX idx_posts_author_id ON posts(author_id);
-- For BelongsToMany relationships
CREATE INDEX idx_post_tags_post_id ON post_tags(post_id);
CREATE INDEX idx_post_tags_tag_id ON post_tags(tag_id);class PostMapper extends Mapper
{
public function withCachedStats()
{
return $this->with(['stats' => function($query) {
// Cache expensive calculated relationships
$query->remember(3600); // Cache for 1 hour
}]);
}
}// Log queries to understand what's being executed
DB::listen(function($query) {
Log::info($query->sql, $query->bindings);
});
$posts = $postMapper->with('author')->get();// Check if a relationship is loaded
$post = $postMapper->find(1);
if ($post->relationLoaded('author')) {
echo "Author is already loaded";
} else {
echo "Author not loaded yet";
}$memoryBefore = memory_get_usage();
$posts = $postMapper->with(['author', 'comments'])->get();
$memoryAfter = memory_get_usage();
$memoryUsed = $memoryAfter - $memoryBefore;
echo "Memory used: " . number_format($memoryUsed / 1024 / 1024, 2) . " MB";Eager loading is essential for building performant applications with Holloway. By understanding and implementing these patterns, you can significantly reduce database queries and improve your application's response times.
Note:
Holloway does not currently support automatic lazy loading of relationships via proxies or magic properties. All relationships must be explicitly specified using
with()or similar methods when querying. Attempting to access an unloaded relationship property will not trigger an automatic database query. This design ensures predictable performance and avoids accidental N+1 query issues, but requires you to plan your data loading strategy up front.