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 Query Optimization for 1M+ Row Tables

By Kokil Thapa | Last reviewed: August 2026

Laravel query optimization for 1M+ row tables stops being optional the moment your admin panel takes eight seconds to load or your API timeout errors start spiking. At this scale, the ORM convenience that makes hiring a Laravel developer attractive can mask expensive full-table scans and memory exhaustion if you rely solely on default Eloquent patterns. The fix is rarely rewriting your entire application; it is usually a combination of targeted composite indexes, disciplined eager loading, and moving heavy aggregation off the PHP layer.

How Do You Diagnose Slow Laravel Queries on Large Tables?

Before changing code, you must confirm what the database engine is actually doing. Guessing leads to adding useless indexes that slow down writes without fixing reads. In my experience working on production Laravel applications with multi-million row datasets, the diagnosis phase saves more time than any premature optimization.

Enable Query Logging and EXPLAIN Analysis

Laravel’s debugbar or telescope is useful for spotting query counts, but for large tables you need the raw SQL and its execution plan. Enable the query log temporarily in a development or staging environment connected to a representative dataset:

<?php // In a controller or command for debugging only DB::enableQueryLog(); $orders = Order::where('status', 'pending') ->whereDate('created_at', '>=', '2026-01-01') ->orderByDesc('created_at') ->limit(50) ->get(); $queries = DB::getQueryLog(); foreach ($queries as $query) { Log::debug('SQL: ' . $query['query']); Log::debug('Bindings: ', $query['bindings']); }

Copy the logged SQL and run EXPLAIN ANALYZE (MySQL 8.0+) or EXPLAIN (ANALYZE, BUFFERS) (PostgreSQL 16+) directly against the database. Look for these red flags:

  • type: ALL — Full table scan on 1M+ rows is almost always wrong for user-facing queries.
  • rows examined >> rows returned — The engine is reading thousands of rows to find dozens.
  • Using filesort — Sorting happens in memory/disk after retrieval because no index covers the ORDER BY.
  • Using temporary — GROUP BY or DISTINCT cannot use an index and creates a temp table.
Large Table Query Diagnosis FlowSlow Endpoint IdentifiedCapture SQL via DB::getQueryLog()Run EXPLAIN ANALYZE on DBFull Scan / FilesortAdd Composite IndexHigh Row ExaminedRefine WHERE / Select ColsN+1 DetectedEager Load + ConstraintValidate fix by re-running EXPLAIN after change
Systematic approach to diagnosing Laravel query performance issues before applying optimizations

Profile Hydration Cost Separately from Query Time

A common mistake is assuming a slow endpoint means a slow SQL query. On a legal-tech portal I built with extensive case records, the SQL executed in 40ms but the endpoint took 2.3 seconds because Eloquent was hydrating 5,000 model instances with relationships. Use toSql() and benchmark the raw DB call versus the Eloquent call. If the gap is large, your bottleneck is PHP object creation, not the database. This distinction determines whether you should optimize the index or switch to cursor(), chunking, or raw selects.

Which Indexing Strategies Actually Work for Million-Row Eloquent Queries?

Indexes are the single highest-impact lever for Laravel query optimization for 1M+ row tables, but only if they match your actual access patterns. A generic index on every foreign key is insufficient for complex filtering.

Build Composite Indexes Matching Your Where Clause Order

MySQL and PostgreSQL use B-tree indexes left-to-right. An index on (status, created_at) helps WHERE status = ? ORDER BY created_at but does NOT help WHERE created_at > ? alone. Examine your most frequent slow queries and build indexes that cover the equality conditions first, then range/sort columns.

// Migration example for an orders table with 2M+ rows Schema::table('orders', function (Blueprint $table) { // Covers: WHERE user_id = ? AND status = ? ORDER BY created_at DESC $table->index(['user_id', 'status', 'created_at'], 'idx_user_status_created'); // Covers: Admin listing filtered by status + date range $table->index(['status', 'created_at'], 'idx_status_created'); });

Avoid Over-Indexing Write-Heavy Tables

Every index slows INSERT and UPDATE operations. On high-throughput eCommerce order tables, I have seen write latency double after adding three unnecessary composite indexes. Audit indexes quarterly using sys.schema_unused_indexes in MySQL or pg_stat_user_indexes in PostgreSQL. Drop anything unused for 90 days unless it serves a known seasonal report.

ScenarioRecommended Index StrategyCommon Mistake
User dashboard (filtered by user + status)Composite: (user_id, status, created_at)Separate indexes on each column
Admin search with LIKE prefixFull-text index or dedicated search serviceB-tree index on varchar with leading wildcard
Date-range reports(created_at) or partitioning by monthComposite starting with non-date column
Soft-deleted entity listingPartial index WHERE deleted_at IS NULL (PG) or include in composite (MySQL)Ignoring soft-delete filter in index design

Consider Covering Indexes for Read-Only Endpoints

If an API endpoint only needs id, title, and published_at from a posts table, a covering index on (status, published_at, id, title) lets MySQL satisfy the query entirely from the index without touching the main table heap. This eliminates random I/O and can reduce response time by 70–90% on million-row tables. The trade-off is index size; measure before deploying.

How Do You Eliminate N+1 Problems Without Loading Too Much Data?

N+1 queries are the most frequent cause of catastrophic performance in Laravel applications at scale, but the naive fix of eager loading everything creates its own memory crisis on large datasets.

Constrain Eager Loads Explicitly

Never write with('comments') on a listing page showing 100 posts when each post has 500 comments. You just loaded 50,000 comment models into memory. Always constrain:

$posts = Post::with(['comments' => function ($query) { $query->select('id', 'post_id', 'body', 'user_id') ->where('is_approved', true) ->latest() ->limit(10); }, 'author:id,name']) ->select('id', 'title', 'slug', 'author_id', 'published_at') ->where('published_at', '<=', now()) ->paginate(20);

This pattern ensures you load exactly what the view needs. On a directory site with vendor listings and reviews, this reduced peak memory from 380MB to 45MB per request.

N+1 Problem (101 Queries)SELECT * FROM posts LIMIT 100SELECT * FROM comments WHERE post_id=1SELECT * FROM comments WHERE post_id=2...SELECT * FROM comments WHERE post_id=100Result: 101 queries, massive memoryAll columns loaded, no constraintsConstrained Eager Load (2 Queries)SELECT id,title,slug FROM postsLIMIT 100SELECT id,post_id,body FROM commentsWHERE post_id IN (1..100)AND is_approved=1 LIMIT 10/postResult: 2 queries, minimal memoryOnly needed columns, filtered set
Visual comparison showing why unconstrained eager loading fails at scale and how constrained loading fixes both query count and memory usage

Use Lazy Eager Loading for Conditional Relations

Sometimes you cannot determine which relations to load upfront. Instead of eagerly loading everything "just in case," use loadMissing() inside loops or resource classes. This prevents duplicate loads while avoiding upfront over-fetching:

// In an API Resource or Blade component public function toArray($request): array { $this->resource->loadMissing(['tags:id,name']); return [ 'id' => $this->id, 'title' => $this->title, 'tags' => TagResource::collection($this->tags), ]; }

Detect N+1 Automatically in Development

Add the beyondcode/laravel-query-detector package to your dev dependencies. It alerts you during local development whenever an N+1 pattern occurs. Combine this with Laravel Debugbar’s query timeline to catch regressions before they reach production. Never rely on manual code review alone for a codebase with hundreds of endpoints.

When Should You Bypass Eloquent for Aggregations and Reports?

Eloquent excels at CRUD operations on individual entities. It fails at analytics, dashboards, and exports on million-row tables because it hydrates every matched row into a PHP object. For these workloads, bypass the ORM deliberately.

Push Aggregations to the Database Engine

Counting, summing, averaging, and grouping should never happen in PHP when the dataset exceeds 10,000 rows. The database engine has optimized C++ routines for this; PHP does not.

// WRONG for 1M+ rows $totalRevenue = Order::whereYear('created_at', 2026) ->get() ->sum('total_amount'); // RIGHT — single value returned from DB $totalRevenue = Order::whereYear('created_at', 2026) ->sum('total_amount'); // Complex grouping without hydration $monthlyStats = DB::table('orders') ->selectRaw("DATE_FORMAT(created_at, '%Y-%m') as month, SUM(total_amount) as revenue, COUNT(*) as orders") ->where('created_at', '>=', '2026-01-01') ->groupByRaw("DATE_FORMAT(created_at, '%Y-%m')") ->orderBy('month') ->get();

Use Cursor or Chunk for Bulk Processing

When you must process every row (data migration, export, batch update), never use all() or get(). Use cursor() for read-only streaming or chunkById() for safe batch updates:

// Memory-safe processing of 2M records Order::where('status', 'completed') ->chunkById(500, function ($orders) { foreach ($orders as $order) { // Process each order SyncToAccountingJob::dispatch($order); } }, column: 'id');

Note the use of chunkById instead of chunk. The latter uses OFFSET which degrades to O(n²) on large tables because MySQL must skip progressively larger result sets. chunkById uses a WHERE clause on the primary key, maintaining constant performance regardless of position.

Query Method Decision Tree by Data VolumeWhat operation?Single Entity CRUD< 1000 rows affectedFiltered Listing / SearchPaginated, indexedAggregation / Bulk ProcessReports, exports, statsUse EloquentModels, relations, eventsEloquent + ConstraintsSelect cols, limit eager loadsQuery Builder / RawDB::table, cursor, chunkByIdBest DX, acceptable perfBalance of safety + speedMax throughput, zero hydration
Practical decision framework for selecting the appropriate query method based on operation type and data volume in Laravel applications

Leverage Database Views for Complex Recurring Reports

If a dashboard query joins four tables and aggregates across millions of rows, encapsulate it in a database view. Laravel can query views like any table, and you can add indexes to materialized views in PostgreSQL or indexed views in MySQL Enterprise. This moves complexity out of PHP and lets the database optimizer cache execution plans. I have used this pattern successfully for financial reporting dashboards where the same aggregation ran on every page load.

How Do You Maintain Performance as Data Grows Beyond Initial Optimization?

Optimization is not a one-time task. Tables that perform well at 1M rows may degrade at 10M without architectural changes. Build monitoring and maintenance into your deployment workflow.

Implement Slow Query Monitoring in Production

Configure MySQL’s long_query_time to 1 second (or lower for APIs) and enable the slow query log. Parse it weekly with pt-query-digest from Percona Toolkit to identify new bottlenecks. Alternatively, use Laravel Pulse (available in Laravel 12.x) to visualize slow queries directly in your application dashboard. Set up alerts for queries exceeding thresholds rather than waiting for user complaints.

Plan Partitioning Before You Need It

For tables that grow predictably (logs, transactions, events), implement range partitioning by date before hitting 5M+ rows. Partitioning allows MySQL to prune irrelevant partitions during queries, effectively reducing the scanned dataset. Adding partitioning retroactively to a live 20M row table is risky and requires downtime; planning it during initial schema design avoids future pain.

-- Example: Monthly partitioning for an audit_logs table ALTER TABLE audit_logs PARTITION BY RANGE (YEAR(created_at) * 100 + MONTH(created_at)) ( PARTITION p202601 VALUES LESS THAN (202602), PARTITION p202602 VALUES LESS THAN (202603), PARTITION p202603 VALUES LESS THAN (202604), PARTITION pmax VALUES LESS THAN MAXVALUE );

Archive Cold Data Aggressively

Most applications have a long tail of historical data that is rarely queried but inflates table size and index depth. Move records older than your active query window to an archive table or separate database. Keep the hot table lean. On an eCommerce project, archiving orders older than two years reduced the active orders table from 4.2M to 800K rows, cutting average query time by 60% without any code changes.

Review Execution Plans After Major Deployments

Data distribution changes over time. An index that was optimal when 80% of orders were "pending" may become inefficient when that drops to 5%. Schedule quarterly reviews of your top 20 queries’ execution plans. Update statistics with ANALYZE TABLE after bulk imports. Treat query performance as a living metric, not a solved problem.

Actionable Next Steps for Laravel Query Optimization

Laravel query optimization for 1M+ row tables is achievable without abandoning the framework or rewriting your application. Start today by enabling query logging on your slowest endpoint, running EXPLAIN ANALYZE, and adding one targeted composite index. Then audit your eager loads for missing constraints. Finally, move your heaviest aggregations to the database layer. These three steps alone resolve the majority of large-table performance issues I encounter in production systems.

If your team needs hands-on support diagnosing and fixing large-table performance bottlenecks in Laravel, reach out to discuss your specific situation. I regularly help teams optimize databases for legal-tech platforms, eCommerce systems, and SaaS applications handling millions of records. For related infrastructure concerns, see our guide on MySQL optimization for SaaS applications or scaling Laravel queues for high-traffic workloads.

Frequently Asked Questions

Enable DB::listen in a service provider or use Laravel Debugbar to log queries exceeding 100ms. In production, enable the slow_query_log in MySQL with long_query_time set to 1 second. Review logs for full table scans on tables over 1M rows.

N+1 occurs when Eloquent executes one query per parent record instead of eager loading relations. On 1M+ row tables, this causes thousands of redundant lookups. Always use with() to preload relationships and verify via Debugbar that only two queries execute regardless of result set size.

Use B-tree indexes for equality and range searches, composite indexes matching WHERE clause column order, and covering indexes to avoid table lookups. For JSON columns in MySQL 8.0+, use generated columns with functional indexes. Avoid indexing low-cardinality boolean or status fields unless combined in composite keys.

Cursor uses PHP generators with single-query streaming, consuming less memory than chunk’s repeated OFFSET pagination. Chunk works better when updating records since it avoids iterator invalidation during writes. For read-only exports on 1M+ row tables, cursor typically reduces execution time by thirty to fifty percent.

Audit and fix typically costs NPR 25,000–75,000 (USD 190–560) depending on complexity. Simple index additions take hours; refactoring eager loading or restructuring schemas may require days. Budget projects often see eighty percent gains from indexing alone before expensive code changes.

Raw queries bypass ORM overhead but sacrifice maintainability and model events. Use them selectively for reporting aggregations or batch operations where Eloquent hydration dominates runtime. Keep business logic in models; reserve raw DB::table calls for proven bottlenecks identified through profiling, not premature optimization.

MySQL uses only one index per WHERE clause unless index merge applies. A composite index on (status, created_at) serves both filtered and sorted queries efficiently. Three separate indexes force optimizer guesses and extra lookups. Design composites following your most frequent query patterns, placing equality columns before range columns.

Keyset pagination using WHERE id > last_seen_id ORDER BY id LIMIT 50 outperforms OFFSET at scale since it avoids scanning discarded rows. Implement via cursor pagination in Laravel 12. Reserve traditional offset pagination for admin interfaces with small result sets or when users require random page access.

Cache COUNT, SUM, and GROUP BY results that change infrequently. Invalidate on model events using tagged caches. For dashboards querying 1M+ rows, precompute aggregates hourly via scheduled commands. This reduces database load from seconds to milliseconds. Never cache user-specific data without proper tag isolation.

Partition when queries consistently filter by date ranges or tenant IDs and indexes still cause slow scans. Range partitioning by created_at helps archival workflows. Hash partitioning distributes load evenly. Test first—partitioning adds complexity to migrations and foreign keys. Most 1M-row tables need better indexes, not partitions.

Yes. Run DB::statement('EXPLAIN ANALYZE ' . $query->toSql()) with bindings to see actual execution times per operation. Compare estimated versus actual rows to detect stale statistics. Look for filesort, temporary tables, or type=ALL indicating missing indexes. Run ANALYZE TABLE after bulk inserts to refresh cardinality estimates.

Leading wildcards (%term) prevent index usage entirely. Use FULLTEXT indexes with MATCH AGAINST for natural language search in MySQL 8.0+. For prefix searches (term%), standard B-tree indexes work. Consider Meilisearch or Typesense integration for complex fuzzy matching rather than forcing database-level text search at scale.

Increase innodb_buffer_pool_size to seventy percent of available RAM. Set innodb_log_file_size to 256MB minimum. Enable query_cache_type=0 in MySQL 8.0+ as it is deprecated. Configure connection pooling via PgBouncer or ProxySQL for high-concurrency apps. Tune max_connections based on PHP-FPM worker count to prevent exhaustion.

Use pt-online-schema-change or gh-ost for zero-downtime index creation on busy tables. Laravel migrations block writes during ALTER TABLE. Schedule during low-traffic windows if tools are unavailable. Always test on staging with production-scale data first. Monitor replication lag and disk I/O during execution to avoid cascading failures.

Laravel 12 includes optimized eager loading algorithms and improved query builder internals, but gains depend on your specific bottlenecks. Framework upgrades rarely fix architectural issues like missing indexes or N+1 problems. Profile before and after upgrading. Combine with PHP 8.4 JIT and OPcache improvements for measurable throughput increases on CPU-bound query workloads.

Share this article

Quick Contact Options
Choose how you want to connect me: