MySQL Query Profiling in Laravel: EXPLAIN ANALYZE, Slow Query Log, and Index Tuning
#laravel #mysql #performance #eloquent #database

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

3 min read Mohamed Said Mohamed Said

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.

Found this useful?

Frequently Asked Questions

3 questions
Q01 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.
Q02 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.
Q03 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.

Continue reading

More Articles

View all