Skip to content

Query Profiler

Muhammet Şafak edited this page May 24, 2026 · 1 revision

Query Profiler

The query log is a per-Connection in-memory buffer of every statement executed while logging is enabled. Use it to inspect what SQL your app actually issues, measure query timings, and catch N+1 problems.

Enabling

$db->enableQueryLog();

$db->read('users', ['id', 'name'], ['active' => 1]);
$db->read('posts', ['id', 'title'], ['user_id' => 5]);

print_r($db->getQueryLogs());

Output:

Array
(
    [0] => Array
        (
            [query] => SELECT id, name FROM users WHERE active = :active
            [args]  => Array ( [:active] => 1 )
            [timer] => 0.000642
        )

    [1] => Array
        (
            [query] => SELECT id, title FROM posts WHERE user_id = :user_id
            [args]  => Array ( [:user_id] => 5 )
            [timer] => 0.000478
        )
)

Log entry shape

Key Type Meaning
query string The SQL string handed to PDO. Already includes named placeholders.
args array<string, mixed>|null The bound parameter map (may be null for raw query() calls without parameters).
timer float Wall-clock seconds, microtime(true) difference.

Disabling

$db->enableQueryLog();
$db->read('users');           // recorded

$db->disableQueryLog();
$db->read('users');           // NOT recorded

print_r($db->getQueryLogs()); // only the first read appears

Disabling preserves existing entries — it only stops new ones from being appended.

Bootstrap-time enabling

Set queryLogs: true in the credentials array to have the log running before any code touches the Database:

DB::createImmutable([
    'dsn'       => '',
    'queryLogs' => true,
]);

This is convenient for development — flip it on in your local .env and forget about it.

Use cases

Finding N+1 problems

The most common reason to enable the profiler.

$db->enableQueryLog();
$users = $db->read('users')->asAssoc()->rows();

foreach ($users as $user) {
    $posts = $db->read('posts', ['*'], ['user_id' => $user['id']])->asAssoc()->rows();
    // …
}

$logs = $db->getQueryLogs();
echo count($logs); // 1 + number of users → that's an N+1

Fix by joining or pre-fetching:

$rows = $db->select('users.id', 'users.name', 'posts.id AS post_id', 'posts.title')
    ->from('users')
    ->leftJoin('posts', 'posts.user_id = users.id')
    ->read()
    ->asAssoc()
    ->rows();

// Or pre-fetch all related rows in one IN()
$ids   = array_column($users, 'id');
$posts = $db->read('posts', ['*'], [DB::raw('user_id IN (' . implode(',', $ids) . ')')]);

Slow-query budget

$slow = array_filter($db->getQueryLogs(), fn ($entry) => $entry['timer'] > 0.05);

foreach ($slow as $entry) {
    error_log(sprintf('SLOW: %.3fs %s', $entry['timer'], $entry['query']));
}

Dev dashboard

Inject a header summarizing query activity in development:

register_shutdown_function(function () use ($db) {
    if (headers_sent()) {
        return;
    }
    $logs = $db->getQueryLogs();
    header('X-Query-Count: ' . count($logs));
    header('X-Query-Time: ' . round(array_sum(array_column($logs, 'timer')) * 1000, 2) . 'ms');
});

Assertion in tests

public function test_does_not_n_plus_one(): void
{
    $this->db->enableQueryLog();

    $this->repository->loadAllWithRoles();

    self::assertLessThanOrEqual(2, count($this->db->getQueryLogs()), 'Should run at most two queries.');
}

Cost

The log lives in process memory and grows unbounded. For long-running CLI workers, drain it periodically:

while ($job = $queue->pop()) {
    $job->handle();

    if (count($db->getQueryLogs()) > 10_000) {
        // Reset the underlying buffer
        $db->getConnection()->setQueryLogs(false)->setQueryLogs(true);
    }
}

In production, leave queryLogs off unless you're actively profiling — the timing measurement itself is cheap, but unbounded memory growth is not.

Profiler vs failure log

Failure log (log credential) Query profiler (queryLogs)
Captures Errors only Every successful + failed query
Storage External sink (file, Monolog, …) In-process memory array
Default Disabled Disabled
Cost when enabled Negligible (only fires on error) One microtime() call per query, plus memory
Use case Production error tracking Development profiling, test assertions

They are independent — enable both, either, or neither.

See also

  • Logging and Debug — for failure-only logging
  • Recipes — concrete profiler-driven patterns
  • FAQ — "why is timer zero?", "can I get the unbound SQL?"

Clone this wiki locally