Layer 1 — Database slow-query log
Your database already knows which queries were slow. Turn the log on as a baseline.
PostgreSQL — postgresql.conf
log_min_duration_statement = 500 # log anything over 500ms
log_line_prefix = '%m [%p] %q%u@%d '
log_statement = 'none'MySQL — 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 = 1Cheap, reliable, catches everything. Missing: request context. You get the SQL but not which controller fired it or what the user was doing. Useful for capacity planning and index design; not actionable for "the /orders page is slow."
Layer 2 — Laravel query hooks
Laravel gives you two hooks in AppServiceProvider::boot().
Fire once per request when total query time exceeds 500ms
use Illuminate\Support\Facades\DB;
DB::whenQueryingForLongerThan(500, function ($connection, $event) {
logger()->warning('Slow request — SQL queries exceeded 500ms', [
'url' => request()->fullUrl(),
'method' => request()->method(),
'user_id' => auth()->id(),
]);
});Fire on every query (expensive — sample in production)
DB::listen(function ($query) {
if ($query->time < 100) return;
logger()->warning('Slow query', [
'sql' => $query->sql,
'bindings' => $query->bindings,
'time_ms' => $query->time,
'connection' => $query->connectionName,
]);
}); DB::listen runs for every query — at 1,000 req/sec with 10 queries per request, that's 10,000 callback invocations per second. Keep the callback cheap (no DB writes, no network calls) and prefer the whenQueryingForLongerThan variant for production-facing logic.
Layer 3 — Full APM with pattern grouping
The real unlock is grouping queries by fingerprint. A raw log says "this query took 800ms at 14:23." Pattern grouping says "this query pattern has fired 840,000 times today with a p95 of 640ms — up from 90ms yesterday."
Fingerprinting normalizes bindings:
-- Raw SQL:
SELECT * FROM orders WHERE user_id = 742 AND status = 'paid' ORDER BY created_at DESC LIMIT 10;
SELECT * FROM orders WHERE user_id = 891 AND status = 'paid' ORDER BY created_at DESC LIMIT 10;
-- Fingerprint (both roll up into one):
SELECT * FROM orders WHERE user_id = ? AND status = ? ORDER BY created_at DESC LIMIT ?;Options that do this out of the box:
- Laravel Nightwatch Cloud — official
- NightOwl — BYOD Postgres dashboard on the same Nightwatch instrumentation
- Sentry / Scout / New Relic — generic APMs, weaker at Laravel-specific views
What to actually fix first
Sorted by impact:
- Queries fired 1,000x+ per request — N+1s. See our N+1 guide.
- Patterns with high call count and mid-range p95 — 0.5M calls at 150ms p95 is 75,000 seconds of DB time per day. Missing index, usually.
- Individually slow queries (p95 > 1s) — often full table scans or large OFFSETs. Use EXPLAIN ANALYZE to see the plan.
- Queries with growing duration trend — healthy yesterday, slow today. Usually the table grew past an index's sweet spot.
THE EASY WAY
NightOwl groups every query by pattern with p95 trending
NightOwl fingerprints every SQL statement, rolls up by pattern, and tracks count + p95 duration over any time range. Drill into a pattern to see the exact requests and bindings. All the instrumentation is the official laravel/nightwatch package — zero runtime impact on request path.
composer require nightowl/agent
php artisan nightowl:installData stays in your PostgreSQL. From $5/month flat.