68 Circular Road, #02-01, Singapore 049422hello@nexura.ltd
HomeAboutContact
Get a Quote
IT & SOFTWARE 27 Jun 2026 12 MIN READ

Optimizing Laravel Eloquent: Advanced Database Relationship and Query Performance

Master Laravel Eloquent performance optimization with eager loading, query caching, composite indexing, and advanced techniques to eliminate N+1 problems and scale your application.

P
By Per Lee Chean
Optimizing Laravel Eloquent database performance with eager loading, query caching, and indexing strategies

Laravel's Eloquent ORM is one of the most elegant database abstraction layers in modern web development. Its ActiveRecord implementation lets developers interact with databases using expressive, readable PHP syntax. But that elegance comes with a hidden cost: without deliberate Laravel Eloquent performance optimization, your application can quietly generate hundreds of redundant queries, consume excessive memory, and buckle under production-scale traffic.

We've audited Laravel applications at Nexura Tech that were executing over 800 queries per page load — all because of unoptimized Eloquent relationships. After applying the strategies outlined in this guide, those same pages dropped to under 15 queries with response times improving by 90%. Whether you're building an MVP with Laravel and Tailwind or scaling a SaaS backend, these techniques are essential knowledge for every Laravel developer.

Understanding the N+1 Query Problem

The N+1 query problem is the single most common performance killer in Eloquent applications. It occurs when your code executes one query to retrieve a collection of models (the "1"), then executes an additional query for each model to load a related resource (the "N"). For a page displaying 100 orders with their customers, that's 101 database queries instead of 2.

Here's a classic example of the N+1 problem in action:

// ❌ N+1 Problem: 1 query for posts + N queries for each author
$posts = Post::all(); // SELECT * FROM posts

foreach ($posts as $post) {
    echo $post->author->name;
    // Each iteration: SELECT * FROM authors WHERE id = ?
}

// With 200 posts, this generates 201 queries!

The fix is deceptively simple — eager loading with the with() method collapses those N+1 queries into just 2:

// ✅ Eager Loading: Only 2 queries total
$posts = Post::with('author')->get();
// Query 1: SELECT * FROM posts
// Query 2: SELECT * FROM authors WHERE id IN (1, 2, 3, ...)

foreach ($posts as $post) {
    echo $post->author->name; // No additional query — already loaded
}

Detecting N+1 with Laravel Debugbar

Before you can fix N+1 problems, you need to detect them. Laravel Debugbar is the gold standard tool for this. Install it as a dev dependency and it will display every query executed during a request, including duplicates:

composer require barryvdh/laravel-debugbar --dev

For automated detection in Laravel 8.43+, you can use the built-in Model::preventLazyLoading() method, which throws an exception whenever a relationship is lazy-loaded:

// app/Providers/AppServiceProvider.php
public function boot(): void
{
    // Prevent lazy loading in non-production environments
    Model::preventLazyLoading(!app()->isProduction());

    // Optional: Log instead of throwing exceptions
    Model::handleLazyLoadingViolationUsing(function ($model, $relation) {
        Log::warning("Lazy loading detected: {$model}::{$relation}");
    });
}

This is a game-changer for teams. Enable it in your development and staging environments, and N+1 problems become impossible to ship to production unnoticed.

Mastering Eager Loading Strategies

Eager loading is the primary weapon in your Laravel Eloquent performance optimization arsenal. Laravel provides several approaches depending on when and how you need to load relationships.

Basic and Nested Eager Loading

// Load multiple relationships
$orders = Order::with(['customer', 'items', 'shippingAddress'])->get();

// Nested eager loading — load the product for each order item
$orders = Order::with('items.product')->get();
// Query 1: SELECT * FROM orders
// Query 2: SELECT * FROM order_items WHERE order_id IN (...)
// Query 3: SELECT * FROM products WHERE id IN (...)

// Constrained eager loading — filter the related models
$users = User::with(['posts' => function ($query) {
    $query->where('status', 'published')
          ->orderBy('created_at', 'desc')
          ->limit(5);
}])->get();

Lazy Eager Loading with load()

Sometimes you don't know which relationships you'll need until after the initial query. The load() method lets you eager load relationships on an already-retrieved collection:

$posts = Post::all();

if ($request->has('include_comments')) {
    $posts->load('comments.author'); // Eager load conditionally
}

// loadMissing() only loads if not already loaded — prevents duplicate queries
$posts->loadMissing('author');

Eager Loading Counts and Aggregates

// Load relationship counts without loading the full relation
$posts = Post::withCount('comments')->get();
// Adds a 'comments_count' attribute: SELECT posts.*, (SELECT COUNT(*) ...) as comments_count

foreach ($posts as $post) {
    echo "{$post->title} has {$post->comments_count} comments";
}

// Load aggregates
$authors = Author::withAvg('posts', 'views')
    ->withMax('posts', 'created_at')
    ->withSum('posts', 'word_count')
    ->get();

Reducing Memory with Selective Column Loading

A frequently overlooked aspect of Laravel Eloquent performance optimization is column selection. By default, Eloquent selects all columns (SELECT *), which means every query loads data your application never uses. On tables with TEXT or JSON columns, this waste is enormous.

// ❌ Loads everything, including large 'body' and 'metadata' columns
$posts = Post::with('author')->get();

// ✅ Select only needed columns — reduces memory by up to 80%
$posts = Post::select(['id', 'title', 'slug', 'author_id', 'created_at'])
    ->with(['author' => function ($query) {
        $query->select(['id', 'name', 'avatar_url']);
    }])
    ->get();
Critical Rule: When using select() with eager loading, you must include the foreign key column (e.g., author_id) in the parent's select and the primary key (id) in the related model's select. Without these keys, Eloquent cannot match related models to their parents, and the relationship will return null.

The addSelect() method is useful for subquery selects without overriding existing column selections:

$users = User::addSelect([
    'last_login_at' => Login::select('created_at')
        ->whereColumn('user_id', 'users.id')
        ->latest()
        ->limit(1)
])->get();

Chunking and Lazy Collections for Batch Processing

When processing large datasets — generating reports, running data migrations, or syncing records — loading everything into memory at once is a recipe for Allowed memory size exhausted errors. Laravel provides chunking and lazy collections to process records in manageable batches.

// chunk() — process 500 records at a time
Order::where('status', 'completed')
    ->chunk(500, function ($orders) {
        foreach ($orders as $order) {
            $order->generateInvoice();
        }
    });

// chunkById() — safer for updates, uses WHERE id > ? instead of OFFSET
User::where('email_verified', false)
    ->chunkById(200, function ($users) {
        foreach ($users as $user) {
            $user->sendVerificationReminder();
        }
    });

// Lazy collections — PHP generators under the hood, ultra-low memory
Post::where('published', true)->lazy()->each(function ($post) {
    $post->reindexSearchEngine();
});

// lazyById() — combines lazy evaluation with ID-based chunking
Product::lazyById(100)->each(function ($product) {
    SearchIndex::update($product);
});

Use chunkById() over chunk() when you're modifying records during iteration. Standard chunking uses OFFSET which causes rows to be skipped when records are deleted or updated. The chunkById() approach uses a WHERE id > ? cursor, which is both safer and faster on large tables because it avoids the performance penalty of high OFFSET values.

Database Indexing Strategy for Eloquent

No amount of Eloquent optimization can compensate for missing database indexes. Your indexing strategy should be informed by your actual query patterns, and Laravel migrations make index management straightforward. For deeper PostgreSQL-specific tuning, our PostgreSQL Query Tuning Guide covers the database side in detail.

Composite Indexes

Composite (multi-column) indexes accelerate queries that filter or sort on multiple columns simultaneously. The column order matters — place the most selective column first:

// Migration: Create composite index
Schema::table('orders', function (Blueprint $table) {
    // Optimizes: WHERE status = ? AND created_at > ?
    $table->index(['status', 'created_at']);

    // Optimizes: WHERE tenant_id = ? AND customer_id = ?
    $table->index(['tenant_id', 'customer_id']);
});

// The query that benefits from this composite index:
Order::where('status', 'pending')
    ->where('created_at', '>', now()->subDays(30))
    ->orderBy('created_at', 'desc')
    ->get();

Covering Indexes

A covering index includes all columns needed by a query, allowing the database to satisfy the query entirely from the index without reading the table rows (an "index-only scan"):

// Covering index for a dashboard summary query
Schema::table('orders', function (Blueprint $table) {
    // Covers: SELECT status, COUNT(*), SUM(total) GROUP BY status
    $table->index(['status', 'total']);
});

// This query can now be served entirely from the index:
Order::selectRaw('status, COUNT(*) as count, SUM(total) as revenue')
    ->groupBy('status')
    ->get();

Indexing Foreign Keys and Polymorphic Relations

Schema::table('comments', function (Blueprint $table) {
    // Always index foreign keys used in relationships
    $table->index('post_id');

    // Polymorphic relations need a composite index
    $table->index(['commentable_type', 'commentable_id']);
});

Query Caching with Redis at the Eloquent Layer

For queries that don't change frequently — configuration data, product catalogs, dashboard aggregates — caching at the Eloquent layer can eliminate database hits entirely. When combined with a proper Laravel caching strategy, this can reduce database load by orders of magnitude.

use Illuminate\Support\Facades\Cache;

// Basic query caching pattern
$featuredProducts = Cache::remember('products:featured', now()->addHours(6), function () {
    return Product::where('is_featured', true)
        ->with(['category', 'images'])
        ->select(['id', 'name', 'slug', 'price', 'category_id'])
        ->get();
});

// Cache with tag-based invalidation (Redis/Memcached only)
$userOrders = Cache::tags(['orders', "user:{$userId}"])
    ->remember("user:{$userId}:orders", now()->addMinutes(30), function () use ($userId) {
        return Order::where('user_id', $userId)
            ->with('items.product')
            ->latest()
            ->limit(20)
            ->get();
    });

// Invalidate all order caches when an order is created
// In your Order model observer:
public function created(Order $order): void
{
    Cache::tags(['orders', "user:{$order->user_id}"])->flush();
}

A robust caching layer is especially critical when you're choosing between Laravel and Node.js for high-traffic applications — Laravel with Redis caching can match and exceed Node.js throughput for read-heavy workloads.

Implementing a Cacheable Trait

// app/Traits/Cacheable.php
trait Cacheable
{
    public static function getCached(string $key, int $ttlMinutes = 60, ?Closure $query = null)
    {
        return Cache::tags([static::class])->remember($key, now()->addMinutes($ttlMinutes), function () use ($query) {
            return $query ? $query() : static::all();
        });
    }

    public static function flushCache(): void
    {
        Cache::tags([static::class])->flush();
    }

    protected static function bootCacheable(): void
    {
        static::saved(fn () => static::flushCache());
        static::deleted(fn () => static::flushCache());
    }
}

// Usage in your model
class Product extends Model
{
    use Cacheable;
}

// Retrieve with automatic caching
$products = Product::getCached('active-products', 120, function () {
    return Product::where('active', true)->with('category')->get();
});

Subqueries, Raw Expressions, and Bypassing Eloquent

Eloquent is powerful, but it's not always the right tool. For complex analytical queries, bulk operations, and performance-critical paths, knowing when to use raw queries or the query builder directly is a key part of Laravel Eloquent performance optimization.

Eloquent Subqueries

// Subquery select: get each user's latest order date
$users = User::addSelect([
    'latest_order_at' => Order::select('created_at')
        ->whereColumn('user_id', 'users.id')
        ->latest()
        ->limit(1)
])->get();

// Subquery ordering: sort users by their latest order
$users = User::orderByDesc(
    Order::select('created_at')
        ->whereColumn('user_id', 'users.id')
        ->latest()
        ->limit(1)
)->get();

Raw Expressions for Complex Queries

// Use DB::raw() for expressions Eloquent can't generate
$monthlySales = Order::selectRaw('
        DATE_TRUNC(\'month\', created_at) as month,
        COUNT(*) as order_count,
        SUM(total) as revenue,
        AVG(total) as avg_order_value
    ')
    ->where('created_at', '>=', now()->subYear())
    ->groupByRaw('DATE_TRUNC(\'month\', created_at)')
    ->orderByRaw('DATE_TRUNC(\'month\', created_at) DESC')
    ->get();

Bulk Operations: Bypass Eloquent Entirely

// ❌ Eloquent approach: N individual UPDATE queries
User::where('last_active_at', '<', now()->subYear())->get()
    ->each(function ($user) {
        $user->update(['status' => 'inactive']);
    });

// ✅ Single query via query builder — orders of magnitude faster
DB::table('users')
    ->where('last_active_at', '<', now()->subYear())
    ->update(['status' => 'inactive']);

// Bulk insert with upsert (insert or update)
DB::table('products')->upsert(
    $productsArray,           // Data to insert/update
    ['sku'],                  // Unique key columns
    ['price', 'stock_count']  // Columns to update on conflict
);

As a general rule: use Eloquent for application CRUD where you need model events, mutators, and relationships. Use the query builder or raw SQL for reporting, analytics, bulk operations, and any query where you're processing thousands of rows.

Benchmarking and Query Analysis

Optimizing without measurement is guesswork. Laravel provides built-in tools for query logging, and your database provides EXPLAIN ANALYZE for understanding query execution plans.

Laravel Query Log

// Enable query logging
DB::enableQueryLog();

// Run your application logic
$orders = Order::with('items.product')->where('status', 'pending')->get();

// Inspect the queries
$queries = DB::getQueryLog();
foreach ($queries as $query) {
    Log::info('Query', [
        'sql'      => $query['query'],
        'bindings' => $query['bindings'],
        'time_ms'  => $query['time'],
    ]);
}

// Output total query count and time
$totalTime = collect($queries)->sum('time');
Log::info("Total: " . count($queries) . " queries in {$totalTime}ms");

EXPLAIN ANALYZE

// Run EXPLAIN ANALYZE on any query
$explanation = DB::select(
    'EXPLAIN ANALYZE SELECT * FROM orders WHERE status = ? AND created_at > ?',
    ['pending', now()->subDays(30)]
);

// Look for:
// - Seq Scan vs Index Scan (Index Scan = good)
// - Actual rows vs estimated rows (big difference = stale stats)
// - Sort method: external merge (= needs more work_mem)
// - Nested Loop with high row counts (= consider different join strategy)

Custom Benchmarking Middleware

// app/Http/Middleware/QueryBenchmark.php
class QueryBenchmark
{
    public function handle(Request $request, Closure $next)
    {
        DB::enableQueryLog();
        $startTime = microtime(true);

        $response = $next($request);

        $queries = DB::getQueryLog();
        $duration = round((microtime(true) - $startTime) * 1000, 2);

        if (count($queries) > 20 || $duration > 500) {
            Log::warning('Slow request detected', [
                'url'          => $request->fullUrl(),
                'query_count'  => count($queries),
                'duration_ms'  => $duration,
                'slow_queries' => collect($queries)
                    ->filter(fn ($q) => $q['time'] > 50)
                    ->values()
                    ->toArray(),
            ]);
        }

        return $response;
    }
}

Deploy this middleware in staging to automatically flag endpoints that exceed your query budget. We recommend setting thresholds at 20 queries and 500ms for most B2B applications.

Frequently Asked Questions

What is the biggest performance mistake in Laravel Eloquent?

The N+1 query problem is by far the most common and impactful performance issue. It occurs when you access a relationship inside a loop without eager loading, causing one additional query per iteration. A page listing 100 records with one relationship each generates 101 queries instead of 2. Enable Model::preventLazyLoading() in development to catch these issues automatically before they reach production.

When should I use the query builder instead of Eloquent?

Use Laravel's query builder (DB::table()) or raw SQL for bulk operations (mass updates, inserts, deletes), complex analytical queries with multiple aggregations or window functions, reporting dashboards, and any operation processing thousands of rows. Eloquent adds overhead through model hydration, event dispatching, and attribute casting. For a bulk update of 10,000 rows, the query builder can be 50-100x faster than iterating Eloquent models.

How does query caching work with Eloquent and Redis?

Query caching stores the serialized results of Eloquent queries in Redis or Memcached, bypassing the database entirely on subsequent requests. Use Laravel's Cache::remember() method to wrap expensive queries with a cache key and TTL. For cache invalidation, use tagged caches so you can flush specific groups of cached queries when underlying data changes. This is particularly effective for read-heavy endpoints like product listings, configuration lookups, and dashboard aggregates.

How do I choose the right database indexes for my Laravel application?

Start by enabling the query log and identifying your most frequent and slowest queries. Create indexes on columns used in WHERE clauses, ORDER BY, and JOIN conditions. Use composite indexes when queries filter on multiple columns — column order matters, so place the most selective column first. Always index foreign keys used in Eloquent relationships. Run EXPLAIN ANALYZE on critical queries to verify the database is using your indexes. Avoid over-indexing, as each index slows down write operations.

Build High-Performance Laravel Applications with Nexura Tech

Laravel Eloquent performance optimization isn't a one-time task — it's a discipline that must be embedded in your development workflow from day one. From preventing N+1 queries and implementing strategic eager loading, to designing proper indexes and deploying intelligent cache layers, each technique compounds to deliver dramatically faster applications.

At Nexura Tech, we specialize in building and optimizing Laravel applications that handle enterprise-scale traffic without breaking a sweat. Whether you need a performance audit of an existing application, architecture guidance for a new Laravel-based MVP, or a dedicated team to scale your Laravel infrastructure, our Singapore-based engineering team has the expertise to deliver measurable results.

Ready to eliminate performance bottlenecks and scale with confidence? Contact Nexura Tech today for a free Laravel performance assessment and discover how we can help your application handle 10x the traffic with half the infrastructure cost.

LaravelEloquent ORMPHPDatabase OptimizationQuery PerformanceN+1 ProblemEager LoadingRedis CachingDatabase IndexingWeb Development
Work with Nexura

Need Help with Your Digital Strategy?

From custom software to SEO, let's build something great together.