"The app feels slow" is the least debuggable sentence in software. It's true, it's useless, and it's where most performance work starts — a vibe, not a route. The first real job isn't fixing anything, it's turning that vibe into a list: which specific endpoints are slow, for how many requests, and by how much. Until you have that list you're optimizing by guesswork, and the thing you "know" is slow is usually fine while the actual culprit is a route you never think about. Finding the slow endpoints is the step that makes everything after it possible.
Layer 1 — Web server access logs
Nginx and Apache can record request duration. Free, always-on, zero Laravel code.
nginx.conf
log_format timing '$remote_addr - [$time_iso8601] '
'"$request" $status $body_bytes_sent '
'rt=$request_time uct="$upstream_connect_time" '
'uht="$upstream_header_time" urt="$upstream_response_time"';
access_log /var/log/nginx/access.log timing;
Sort the log by $request_time to find slow requests. What you're missing: controller name, user context, query breakdown, per-route aggregation. Fine for a quick baseline, useless for root cause.
Layer 2 — Middleware timing
A Laravel middleware records start/end time and the resolved route. You get controller-aware timing without a full APM.
app/Http/Middleware/MeasureRequestTime.php
use Closure;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Log;
class MeasureRequestTime
{
public function handle(Request $request, Closure $next)
{
$start = microtime(true);
$response = $next($request);
$duration = (microtime(true) - $start) * 1000;
if ($duration > 500) {
Log::warning('Slow request', [
'route' => $request->route()?->uri(),
'controller' => $request->route()?->getActionName(),
'method' => $request->method(),
'duration_ms' => round($duration, 2),
'status' => $response->getStatusCode(),
'user_id' => auth()->id(),
]);
}
return $response;
}
}
Register globally in bootstrap/app.php. Good for logging outliers. Missing: aggregation. A single slow request doesn't tell you much — you want to know which route pattern is consistently slow at p95.
Layer 3 — Route aggregation with p95
The real unlock is aggregating by normalized route pattern. One record per request, grouped by route?->uri(), with p95 and p99 percentiles computed over a time window.
Metrics to surface per route:
- Request count
- Error rate (5xx %)
- p50 / p95 / p99 duration
- Throughput (requests/min)
- Average DB time / external HTTP time per request (requires instrumentation layer)
Layer 4 — Per-request trace correlation
Once you've identified /orders/{id} is slow at p95, the next question is why. You need per-request traces that break down the slow request into its component spans: DB queries, cache calls, external HTTP, view rendering.
The Laravel Nightwatch package records every span per request with a shared trace ID. NightOwl and Nightwatch Cloud both consume this data — you drill from slow route to slow request to slow query in three clicks.
Fix priorities — what to tackle first
- High-traffic routes with high p95 — biggest total pain. Often N+1s or missing indexes.
- Low-traffic routes with catastrophic tail — /api/export, /admin/report. Users don't hit them often, but when they do they hurt.
- Routes trending slower over time — data growth outpacing indexes. Profile the plan.
- Routes blocked on external HTTP — move to async/queued work. See the outgoing HTTP guide.
- Routes with high error rate AND high p95 — failing slowly, worst possible combination.
THE EASY WAY
NightOwl aggregates every request by route with p95 and full trace drilldown
The requests dashboard groups by route pattern with count, p95 / p99, and error rate. Click a route to see its slowest requests. Click a request to see every DB query, cache call, and HTTP span. All built on the official laravel/nightwatch instrumentation — zero impact on request path.
composer require nightowl/agent
php artisan nightowl:installFrom $5/month flat. Data in your PostgreSQL.