Skip to content

MySQL Query Profiling in Laravel: EXPLAIN ANALYZE, Slow Query Log, and Index Tuning

Stop guessing why your Laravel app is slow at the database layer. This guide walks through reading EXPLAIN ANALYZE output, enabling the slow query log, and applying targeted index changes that actually move the needle.

Published · Updated · 3 min read

MySQL Query Profiling in Laravel: EXPLAIN ANALYZE, Slow Query Log, and Index Tuning

Why Guessing Doesn't Scale

Most Laravel performance problems live in the database, not in PHP. Yet the default debugging loop—add a dd(), stare at Telescope, shrug—rarely surfaces why a query is slow. This article gives you a repeatable workflow: capture slow queries, read execution plans, and apply the right index.


Step 1: Capture Queries Worth Investigating

Before touching EXPLAIN, know which queries deserve attention. Laravel's query listener is the fastest way to log anything over a threshold during development.

// AppServiceProvider::boot()
DB::listen(function (QueryExecuted $event) {
    if ($event->time > 100) { // milliseconds
        logger()->warning('Slow query', [
            'sql' => $event->sql,
            'bindings' => $event->bindings,
            'time_ms' => $event->time,
            'connection' => $event->connectionName,
        ]);
    }
});

In production, enable MySQL's own slow query log instead:

# my.cnf
slow_query_log = 1
slow_query_log_file = /var/log/mysql/slow.log
long_query_time = 0.5
log_queries_not_using_indexes = 1

Parse the log with pt-query-digest (Percona Toolkit) to rank queries by total time, not just worst single execution.


Step 2: Run EXPLAIN ANALYZE

MySQL 8.0+ supports EXPLAIN ANALYZE, which actually executes the query and reports real row counts alongside estimates. The gap between estimated and actual rows is your first signal.

$sql = User::where('tenant_id', 42)
    ->where('status', 'active')
    ->orderBy('created_at')
    ->toRawSql(); // Laravel 10.15+

$plan = DB::select('EXPLAIN ANALYZE ' . $sql);
dd($plan);

Key fields to read:

  • type: ALL means full table scan — almost always wrong on large tables.
  • key: which index MySQL chose (or NULL if none).
  • rows: estimated rows examined. Multiply across joins for the real cost.
  • Extra: watch for Using filesort and Using temporary — both indicate missing or wrong indexes.

Reading a Real Plan

-> Sort: users.created_at  (actual rows=18340, loops=1)
    -> Filter: (users.status = 'active')  (actual rows=18340)
        -> Index lookup on users using idx_tenant (tenant_id=42)
           (actual rows=21050, loops=1)

MySQL used idx_tenant to narrow to 21 k rows, then filtered and sorted in memory. The sort on created_at is the bottleneck.


Step 3: Apply a Covering Index

A covering index includes every column the query touches, so MySQL never visits the clustered index (the actual table rows).

// Migration
Schema::table('users', function (Blueprint $table) {
    // Composite: filter columns first, then the ORDER BY column
    $table->index(['tenant_id', 'status', 'created_at'], 'idx_users_tenant_status_created');
});

After adding the index, re-run EXPLAIN ANALYZE:

-> Index range scan on users using idx_users_tenant_status_created
   (actual rows=18340, loops=1)

Using filesort is gone. MySQL walks the index in order and returns rows directly — no sort step, no secondary lookup.


Step 4: Validate in Staging, Not Just Dev

Index effectiveness depends on data distribution. A column with two distinct values (status = active|inactive) may not benefit from an index if 90 % of rows are active. Use SHOW INDEX FROM users and check Cardinality.

For low-cardinality columns, a partial index (MySQL calls it a filtered index via a generated column or a prefix) or reordering the composite index can help. Always compare EXPLAIN ANALYZE output before and after on a dataset that mirrors production row counts.


Takeaways

  • Use DB::listen locally and MySQL's slow query log in production to find real offenders.
  • EXPLAIN ANALYZE (MySQL 8.0+) shows actual row counts — trust it over estimates.
  • Using filesort and Using temporary in Extra are immediate action items.
  • Design composite indexes with equality columns first, then the ORDER BY column.
  • A covering index eliminates secondary row lookups and is often the single highest-impact change.
  • Validate index cardinality with production-scale data before shipping.

Frequently asked questions

Does EXPLAIN ANALYZE actually run the query and affect production data?

Yes — EXPLAIN ANALYZE executes the full query, including writes for DML statements. For SELECT queries this is safe, but run it on a replica or staging environment to avoid production load spikes on expensive queries.

When should I use a composite index versus separate single-column indexes?

MySQL can only use one index per table reference per query (with rare index merge exceptions). A composite index on (tenant_id, status, created_at) is almost always faster than three separate indexes for a query filtering on all three columns, because it avoids the merge overhead and can serve as a covering index.

How do I get the raw SQL with bindings from an Eloquent query in Laravel?

Use `->toRawSql()` (Laravel 10.15+) to get the SQL with bindings already interpolated. For older versions, combine `->toSql()` with `->getBindings()` and pass both to `DB::select('EXPLAIN ANALYZE ?', [$sql])` after manual interpolation.

Mohamed Said

About the author

Senior Backend Engineer specializing in Laravel, scalable SaaS platforms, APIs, and cloud infrastructure. I build secure, high-performance web applications that help businesses grow.

About GitHub LinkedIn WhatsApp Email Address My CV

Have a technical challenge?

Tell me what you’re building. I reply within two working days.

Discuss your project