Kokil Thapa - Professional Web Developer in Nepal
Freelancer Web Developer in Nepal with 15+ Years of Experience

Kokil Thapa is an experienced full-stack web developer focused on building fast, secure, and scalable web applications. He helps businesses and individuals create SEO-friendly, user-focused digital platforms designed for long-term growth.

Laravel Eloquent Advanced Query Patterns for Large Datasets

By Kokil Thapa | Last reviewed: September 2026

When a Laravel application crosses a few hundred thousand rows, the queries that felt fine in staging start timing out in production. Laravel Eloquent Advanced Query Patterns for Large Datasets are not optional extras—they are the difference between a report that finishes in seconds and one that exhausts PHP memory on a shared host. On real client projects—booking systems, legal portals, eCommerce order histories—I have watched teams reach for raw SQL too early, or worse, load entire tables into collections. Eloquent is expressive enough to handle scale if you treat the database as a filter engine, not a data shuttle. This guide walks through the patterns I reach for on production Laravel 13.x apps running PHP 8.5 against MySQL 9.7 or PostgreSQL 18, with pointers to related work on advanced Eloquent techniques for complex applications and database query caching strategies.

Why Do Laravel Eloquent Queries Slow Down on Large Datasets?

Eloquent hides SQL, which is useful until it hides cost. Three problems show up repeatedly on production systems I maintain.

  • Memory blow-ups: Model::all() or get() on an unbounded query loads every matching row into a Collection. PHP 8.5 raises limits, but a 2 GB FPM worker still dies.
  • N+1 queries: One list query plus one query per row for relations. A 5,000-row admin table becomes 5,001 round trips.
  • Offset pagination: LIMIT 20 OFFSET 500000 forces the engine to scan and discard half a million rows. Page numbers feel familiar; performance does not.

The fix is architectural discipline, not abandoning Eloquent. You push filtering, sorting, and aggregation to MySQL or PostgreSQL, stream results through the application in bounded batches, and cache only stable read models. That aligns with modern Laravel architecture best practices and the reporting workloads I have seen on platforms like Adventure Third Pole Trek, where booking and supplier data grows every season.

Large Dataset Query StackHTTP / Queue JobController, Artisan, ExportEloquent Builderselect, where, with, chunkRedis 8.10 CacheTagged invalidationMySQL / PostgreSQLIndexes + EXPLAINBounded Memory OutputCSV, API page, notification batch
Layered stack for Laravel Eloquent advanced query patterns on large datasets — filter in SQL, stream in PHP, cache selectively.

How Do You Stream Large Result Sets Without Running Out of Memory?

Never call get() on an export or nightly sync. Laravel gives you three streaming primitives; pick based on whether you need ORM models, stable ordering, or maximum throughput.

chunk() for batch processing

chunk() runs repeated queries with LIMIT and OFFSET internally. It is simple and works well up to low millions of rows if you keep chunk size modest (200–1000).

Order::query()
    ->where('status', 'paid')
    ->where('created_at', '>=', now()->subYear())
    ->orderBy('id')
    ->chunk(500, function ($orders) {
        foreach ($orders as $order) {
            GenerateInvoicePdf::dispatch($order->id);
        }
    });

Always include a deterministic orderBy on an indexed column—usually the primary key. Without it, rows can shift between chunks during concurrent writes and you will skip or duplicate records.

chunkById() to avoid offset cost

For tables where id is monotonic, chunkById() replaces OFFSET with WHERE id > ?, which uses the primary key index directly. This is my default on order, payment, and audit tables.

AuditLog::query()
    ->where('created_at', '<', now()->subMonths(6))
    ->chunkById(1000, function ($logs) {
        ArchiveAuditLogs::dispatch($logs->pluck('id')->all());
    }, column: 'id');

cursor() and lazy() for single-pass iteration

cursor() uses PHP generators and issues one query with a server-side cursor (MySQL) or repeated fetches depending on driver configuration. Memory stays flat because only one model hydrates at a time.

foreach (Product::query()->select(['id', 'sku', 'stock'])->cursor() as $product) {
    if ($product->stock < 5) {
        LowStockAlert::dispatch($product->id);
    }
}

lazy() (Laravel 12+) batches internally—often the best compromise when you want generator semantics without tuning fetch sizes yourself. On a legal-tech portal I built, switching a document re-index job from get() to lazy(500) dropped peak memory from 512 MB to under 40 MB on the same dataset.

Streaming Pattern Choicechunk / chunkByIdBatch jobs, queuesDispatch per batchLow memory per batchcursor / lazySingle-pass exportsOne row at a timeFlat memory curveAvoid: get() on unbounded queriesAlways select() only needed columnsSkip JSON blobs and longText on list scans
chunkById and cursor/lazy are core Laravel Eloquent advanced query patterns when processing large datasets in production jobs.

How Do You Prevent N+1 Queries on Large Eloquent Result Sets?

N+1 hurts most when the parent query already returns thousands of rows. Eager loading is the first fix; constrained eager loading is the one that scales.

Constrained eager loads

Booking::query()
    ->with([
        'customer:id,name,email',
        'payments' => fn ($q) => $q
            ->select(['id', 'booking_id', 'amount', 'status'])
            ->where('status', 'captured')
            ->latest()
            ->limit(3),
    ])
    ->whereBetween('check_in', [$start, $end])
    ->paginate(50);

Notice column lists on both sides of the relation. MySQL and PostgreSQL require foreign keys in select() when you trim columns—omit booking_id and the relation silently breaks.

withCount and withExists instead of loading children

When you only need aggregates, never hydrate child models:

Vendor::query()
    ->withCount(['deals as active_deals_count' => fn ($q) => $q->where('expires_at', '>', now())])
    ->withExists('kycVerification as is_verified')
    ->orderByDesc('active_deals_count')
    ->paginate(30);

On directory platforms similar to Ajako Deal, listing pages used withCount instead of loading full deal collections—query count dropped from 120+ to 4 per request. Pair this with testing and optimization that includes query assertions in feature tests.

Detect N+1 in development

Install Laravel Debugbar in local environments only. In CI, assert query counts:

$this->assertDatabaseCount('bookings', 50);

$this->get('/admin/bookings')
    ->assertOk();

$this->assertLessThan(10, count(DB::getQueryLog()));
N+1 vs Eager LoadN+1 Pattern1 list query+ N relation queries5001 queries for 5000 rowsEager Load1 list query+ 1 relation query2 queries totalwith(), withCount(), loadMissing()Constrain columns and WHERE on relationsUse API Resources only after query is leanSerialization cost adds up at scale
Eager loading and aggregate helpers are essential Laravel Eloquent advanced query patterns when listing large datasets in admin and API responses.

Which Subquery and Join Patterns Scale Better Than Naive Eloquent?

Some filters are expressible in Eloquent but execute terribly. Subqueries and strategic joins keep the optimiser on indexed paths. The official Laravel 13.x Eloquent documentation covers the builder methods; the production trick is knowing when to use them.

whereIn with a subquery instead of pluck()

A common mistake: pluck thousands of IDs into PHP, then pass them to whereIn. That bloats memory and can exceed max_allowed_packet on MySQL.

User::query()
    ->whereIn('id', function ($query) {
        $query->select('user_id')
            ->from('orders')
            ->where('paid_at', '>=', now()->subDays(30))
            ->groupBy('user_id')
            ->havingRaw('SUM(total) > ?', [50000]);
    })
    ->select(['id', 'name', 'email'])
    ->cursor();

The database correlates internally—no giant ID array crosses the wire.

whereExists for semi-join filters

Product::query()
    ->whereExists(function ($q) {
        $q->selectRaw('1')
          ->from('order_items')
          ->whereColumn('order_items.product_id', 'products.id')
          ->where('order_items.created_at', '>=', now()->subDays(7));
    })
    ->orderByDesc('updated_at')
    ->limit(100)
    ->get();

On PostgreSQL 18, EXISTS often plans as an anti-join with nested loop on indexed foreign keys—verify with EXPLAIN (ANALYZE, BUFFERS). For MySQL 9.7, compare against a plain inner join; optimiser choice varies by statistics.

Read-only replicas and heavy reports

Separate reporting connections in config/database.php so long-running analytics do not contend with checkout transactions. I use this on Nepal Gift Card-style eCommerce where ledger reports scan months of redemptions. Cross-database patterns also appear in PostgreSQL for Laravel developers and CQRS pattern in Laravel when it helps.

PatternBest forWatch out for
chunkById()Archival jobs, queue dispatchNon-monotonic UUID PKs need custom column
cursor() / lazy()CSV export, webhooks replayLong transactions hold locks if you update inside loop
Subquery whereInFilter by aggregate without temp tableMissing indexes on subquery tables
withCountSortable list badgesHeavy counts without partial indexes
Keyset paginationInfinite scroll APIsCannot jump to arbitrary page number
External search (Meilisearch)Full-text on millions of rowsSync lag; not a SQL replacement

How Should You Index and Paginate for Million-Row Laravel Tables?

Application patterns fail if the schema fights them. Index design is part of Eloquent work, not only DBA work.

Composite indexes aligned to WHERE + ORDER BY

If your query is:

Order::where('status', 'shipped')
    ->where('warehouse_id', 3)
    ->orderByDesc('shipped_at');

Add a composite index:

Schema::table('orders', function (Blueprint $table) {
    $table->index(['warehouse_id', 'status', 'shipped_at']);
});

Column order matters: equality filters first, range/sort last. Confirm with EXPLAIN—the MySQL 9.7 EXPLAIN documentation is the reference when rows examined still explode.

Keyset (cursor) pagination for APIs

Replace offset pagination on large tables:

public function index(Request $request)
{
    $cursor = $request->integer('cursor');
    $limit = 50;

    $items = Transaction::query()
        ->when($cursor, fn ($q) => $q->where('id', '<', $cursor))
        ->orderByDesc('id')
        ->limit($limit + 1)
        ->get();

    $hasMore = $items->count() > $limit;
    $page = $items->take($limit);
    $nextCursor = $hasMore ? $page->last()->id : null;

    return TransactionResource::collection($page)
        ->additional(['meta' => ['next_cursor' => $nextCursor]]);
}

Document the contract in your API—clients pass cursor, not page. See Laravel API best practices and building RESTful APIs with Laravel for pagination metadata conventions.

Search beyond SQL

When users expect Google-like search across products or legal articles, offload to Meilisearch or Elasticsearch and treat SQL as the source of truth. The sync job itself should use chunkById. Full setup is covered in Laravel Meilisearch integration—a pattern I prefer over LIKE '%term%' on multi-million-row tables.

Pattern Decision TreeLarge dataset task?Batch job / exportchunkById or cursorUI list / APIpaginate or keysetNeed relations?constrained with()Full-text search?Meilisearch indexStill slow? EXPLAIN + index + cacheRedis 8.10 for stable aggregates
Decision tree for selecting Laravel Eloquent advanced query patterns for large datasets by workload type.

When Should You Cache, Denormalise, or Reach for Raw SQL?

Eloquent is not a vow of purity. On hot read paths—dashboard totals, category counts, exchange rates cached beside Nepal forex rates—store precomputed values.

  1. Cache remember with tags: Wrap expensive aggregates in Cache::tags(['reports'])->remember() when using Redis 8.10. Invalidate tags on write, not TTL alone.
  2. Denormalise counters: Maintain orders_count on customers via observers or queued increments. Reads become O(1).
  3. Raw SQL for reports: Use DB::select() with bound parameters for monthly revenue rollups. Keep them in repository classes, not controllers.
  4. Queue the work: Never run a 90-second query inside an HTTP request. Return 202 and poll, or email the CSV link.

On Mijar Law Associates, client document lists used tagged Redis caches keyed by firm and month; invalidation fired when uploads completed. That sat alongside careful indexing on (client_id, created_at). For schema-flexible audit trails where relational joins hurt, evaluate MongoDB for Laravel real use cases—but default to MySQL or PostgreSQL until you hit a proven pain point.

Validate query payloads during development with a JSON formatter when debugging API filters, and profile before shipping through speed optimization audits. Enterprise builds that mix ledger SQL with application code often benefit from enterprise application development discipline: migrations reviewed for index impact, and slow-query logging enabled in staging.

The Redis documentation covers tagged cache eviction semantics—read it before relying on tags in cluster mode. If you outsource ongoing query tuning after launch, support and maintenance retainers should include quarterly EXPLAIN reviews on the top ten slow queries from the log.

Key Takeaways

  • Stream large tables with chunkById(), cursor(), or lazy()—never unbounded get() on production jobs.
  • Fix N+1 with constrained with(), withCount(), and explicit select() lists on both sides of relations.
  • Replace offset pagination with keyset cursors on APIs serving million-row tables.
  • Push filters into subqueries and whereExists instead of plucking massive ID arrays into PHP.
  • Align composite indexes to your actual WHERE and ORDER BY columns, then verify with EXPLAIN.
  • Cache stable aggregates in Redis with tag invalidation; offload full-text search to Meilisearch when SQL LIKE fails.

People Also Ask

Is Eloquent too slow for millions of rows?

Eloquent overhead per row is small compared to I/O and missing indexes. Millions of rows fail because of unbounded loads, N+1 queries, and offset pagination—not because the ORM exists. Streaming APIs and selective columns keep Eloquent viable at scale.

What chunk size should I use in Laravel?

Start with 500 for balanced memory and query count. Raise toward 1,000–2,000 on narrow tables with few columns; lower to 100–200 when each row triggers heavy processing or large JSON fields. Measure peak memory on a staging clone with production-like data volume.

Should I use DB::raw instead of Eloquent on large datasets?

Use raw SQL for complex reporting queries that Eloquent makes awkward, but keep them in dedicated repository classes with bound parameters. Day-to-day filtering, relations, and streaming still belong on the query builder or Eloquent for readability and testability.

Does lazy loading ever make sense at scale?

Explicit lazy loading (loadMissing()) helps when most rows do not need a relation— for example, only 5% of orders have disputes. Blind lazy loading in loops is the N+1 anti-pattern; intentional lazy loading after a cheap existence check can reduce total queries.

Ship Queries That Survive Real Traffic

Production data always outgrows the seed file. Laravel Eloquent advanced query patterns for large datasets—chunked exports, keyset APIs, subquery filters, index-aware schemas, and selective caching—let you keep expressive ORM code without pretending PHP can hold the whole table. Start with the slowest query in your log, run EXPLAIN, and apply one pattern at a time. If you are planning a high-volume Laravel build—a marketplace, booking engine, or legal portal—and want query architecture reviewed before launch, contact us or explore web development services. Related reading: why Laravel fits Nepali businesses and the Court Marriage In Nepal portal where lean list queries matter for SEO and lead capture.

Frequently Asked Questions

They are chunking, cursors, selective columns, constrained eager loads, subquery filters, and index-aligned WHERE clauses so PHP never materialises millions of rows while query code stays readable and testable.

Three problems recur on production Laravel apps. Unbounded get() or all() loads every matching row into a Collection and exhausts PHP-FPM memory even on PHP 8.5. N+1 queries turn one list request into thousands of round trips when relations load per row. Offset pagination with LIMIT and OFFSET on deep pages forces MySQL 9.7 or PostgreSQL 18 to scan and discard hundreds of thousands of rows before returning twenty. The fix is architectural: filter and aggregate in SQL, stream bounded batches through PHP, and cache stable read models rather than abandoning Eloquent.

Never call get() on exports or nightly sync jobs. Use chunk() for simple batch processing with a deterministic orderBy on an indexed column, typically the primary key, and chunk sizes around 200–1000. Prefer chunkById() on monotonic id columns because it replaces OFFSET with WHERE id > ?, hitting the primary key index directly. For single-pass iteration, cursor() hydrates one model at a time via generators, while lazy() on Laravel 12+ batches internally and often balances memory and throughput. On a legal-tech portal, switching from get() to lazy(500) cut peak memory from 512 MB to under 40 MB.

chunk() runs repeated LIMIT and OFFSET queries internally. It is straightforward and works well into low millions of rows with modest chunk sizes, but deep offsets eventually cost more as tables grow. chunkById() advances using WHERE id > ? on a monotonic column, which uses the primary key index directly and avoids scanning skipped rows. It is the default for order, payment, and audit tables. Both require deterministic ordering; without orderBy, concurrent writes can shift rows between chunks and cause skipped or duplicate records. Non-monotonic UUID primary keys need a custom column for chunkById().

Start with 500 for balanced memory and query count. Raise toward 1,000–2,000 on narrow tables; lower to 100–200 when rows trigger heavy processing or large JSON fields.

cursor() issues one query with a server-side cursor on MySQL or repeated fetches depending on driver configuration, keeping memory flat because only one model hydrates at a time. It suits CSV exports, webhook replays, and read-only scans where you select only needed columns. lazy(), available from Laravel 12 onward, batches internally and is often the best compromise when you want generator semantics without tuning fetch sizes yourself. Watch long transactions: updating rows inside a cursor loop can hold locks. For queue dispatch after filtering, chunkById() is usually safer than cursor() when you need stable batch boundaries.

Eager loading is the first fix, but constrained eager loading scales on admin tables returning thousands of rows. Use with() closures to select only needed columns, filter child relations, and limit rows—always include foreign keys in select() lists or relations silently break on MySQL and PostgreSQL. When you only need counts or existence checks, use withCount() and withExists() instead of hydrating child models. On directory-style platforms, withCount dropped query counts from 120+ to four per request. Install Laravel Debugbar locally and assert query counts in CI feature tests to catch regressions before production.

Constrained eager loading passes a closure to with() that limits columns, filters conditions, sorts, and caps rows on the related query. Example pattern: load a booking’s customer with only id, name, and email, and load the three latest captured payments with explicit column lists. At scale this prevents loading entire payment histories for every row on a paginated list. Both parent and child queries must include foreign key columns in select()—MySQL and PostgreSQL require this when trimming columns. Without it, Eloquent relations return empty or null related models with no obvious error, which is a common production debugging trap.

Eloquent per-row overhead is small compared to I/O and missing indexes. Failures come from unbounded loads, N+1 queries, and offset pagination—not the ORM itself.

A common mistake is pluck()ing thousands of IDs into PHP then passing them to whereIn(), which bloats memory and can exceed MySQL max_allowed_packet. Instead, pass a subquery to whereIn() so the database correlates internally—no giant ID array crosses the wire. whereExists() works well for semi-join filters such as products with recent order items; on PostgreSQL 18 it often plans as an anti-join with nested loop on indexed foreign keys, while MySQL 9.7 may prefer a plain inner join depending on statistics. Always verify with EXPLAIN and ensure subquery tables carry appropriate indexes.

LIMIT 20 OFFSET 500000 forces the database engine to scan and discard half a million rows before returning the page. Page numbers feel familiar to users but performance degrades linearly as users navigate deeper. Admin tables and APIs serving booking histories, order ledgers, or audit logs hit this wall quickly once tables cross a few hundred thousand rows. Replace offset pagination with keyset cursor pagination on large tables: clients pass a cursor value such as the last seen id rather than a page number. Keyset pagination cannot jump to an arbitrary page number, which is an acceptable trade-off for infinite-scroll APIs.

Accept a cursor parameter from the client, query with where('id', '

Align composite indexes to actual WHERE and ORDER BY columns used in production queries. Put equality filters first and range or sort columns last—for example, warehouse_id, status, then shipped_at on an orders table filtered by warehouse and status and sorted by ship date. Index design is part of Eloquent work, not only DBA work; application patterns fail if the schema fights them. Confirm plans with EXPLAIN against MySQL 9.7 or PostgreSQL 18 when rows examined still explode. Partial indexes help heavy withCount aggregates. Review migrations for index impact during enterprise builds and enable slow-query logging in staging before shipping new list endpoints.

Use raw SQL via DB::select() with bound parameters for complex monthly revenue rollups and reporting queries that Eloquent makes awkward, but keep them in dedicated repository classes—not controllers—for readability and testability. Day-to-day filtering, relations, and streaming still belong on the query builder or Eloquent. Never run a ninety-second report query inside an HTTP request; queue the work, return 202, poll, or email a CSV link. On hot read paths like dashboard totals, prefer Cache::tags() with Redis 8.10 and invalidate on write rather than hitting raw SQL on every page load.

When users expect Google-like full-text search across products, legal articles, or directory listings on multi-million-row tables, SQL LIKE '%term%' fails on performance and relevance. Offload to Meilisearch or Elasticsearch and treat SQL as the source of truth. The sync job itself should use chunkById() to stream changes without memory blow-ups. Meilisearch is not a SQL replacement—watch sync lag between the database and the search index. Use SQL for transactional filters and indexed WHERE clauses; use external search for fuzzy, full-text, and faceted experiences. Full setup is covered in Laravel Meilisearch integration guides referenced alongside these Eloquent patterns.

Share this article

0 Comments

Leave a comment

Your email is not published. Comments appear once they have been read. Sign in to have your details filled in.

Quick Contact Options
Choose how you want to connect me: