A request takes 800 ms and someone suggests adding a database index. Someone else wants to cache the whole response. A third person blames the ORM. All three might be wrong, and each fix costs time. The way out is boring but reliable: measure where the time goes before changing anything. This article shows what that measurement looks like for a Laravel request, how to read it, and the mistakes that make a measurement misleading.
Why intuition fails for slow requests
Slow requests rarely have the cause people expect. A few patterns come up again and again:
- The database gets blamed by default. It is the most visible dependency, so it is the first suspect — even when the queries take 2 ms and the time is spent in application code, an external API call or serialisation.
- Many small costs hide behind one number. Forty 5 ms queries look harmless one by one; together they are 200 ms. This is the classic N+1 pattern: a list of 40 items each loading its relation separately.
- Local and production behave differently. An external API that answers in 30 ms from your laptop may take 400 ms from a server in another region.
A timing broken down by step replaces all of these guesses with one fact: this step took this share of the request.
What a request trace contains
A trace records one request as a tree of timed steps. For a typical Laravel request the useful levels are:
- The request itself — total wall-clock time from the kernel receiving it to the response being sent.
- The controller action — time spent inside the matched controller method, which excludes middleware and routing.
- Database queries — each query with its own duration. Laravel exposes these through its database query events (
DB::listen), which report the time the database driver reported for each statement. - Services and external calls — code you care about by name, such as a payment client or a report builder. These usually need a line of instrumentation, because a profiler cannot know which of your classes matter.
Zerethon's Flow builds exactly this tree. Its open-source Laravel adapter registers a global middleware for the request and controller, listens to query events for the database, and offers two calls for the rest:
use Zerethon\Flow\Laravel\Facades\Flow;
$user = Flow::traceService('UserService.fetch', fn () => $this->users->fetch($id));
$rates = Flow::traceExternal('ExchangeRates.latest', fn () => Http::get($ratesUrl)->json());
Each call times the closure, records it as a node in the request's trace, and returns the closure's result unchanged — so wrapping existing code does not change its behaviour.
Reading the result: share, not milliseconds
The most useful number in a trace is not the duration of a step but its share of the total. A step that takes 90 ms in a 100 ms request is the problem; the same 90 ms in a 3-second request is not. Flow reports the top step this way:
Top Bottleneck: UserService.fetch
Duration: 90ms
Impact: 90%
Classification: clear bottleneck
Database: 2ms (2%)
The conclusion writes itself: the database is not the problem, so an index would not help. The service logic is where to look. Flow's ranking is deterministic — every eligible node is scored and the explanation says why one was picked — so two people looking at the same trace reach the same conclusion.
Three outcomes are common when you read traces this way:
- One clear bottleneck (one step above ~50%). Optimise that step and measure again.
- A spread (no step above ~20%). There is no single fix; look for repeated work — the same query run many times, the same object built in a loop.
- Time outside every step. If the steps add up to much less than the total, the time is in something not instrumented yet: middleware, view rendering, serialisation, or a service you have not wrapped.
Spotting N+1 queries in a trace
N+1 queries are easy to see once queries are listed individually: many queries with the same shape, each fast, together large. The fix in Laravel is usually eager loading:
// 1 query for posts + 1 query per post for its author
$posts = Post::latest()->take(40)->get();
// 2 queries in total
$posts = Post::with('author')->latest()->take(40)->get();
Laravel can also refuse lazy loading in development so the mistake fails loudly: Model::preventLazyLoading(! app()->isProduction());
Measuring without fooling yourself
- Measure the same request more than once. The first request after a deploy pays for cold caches (config, routes, OPcache). Compare warm requests.
- Measure where it is slow. Production data volume and network distance change the answer. Flow's adapter runs in production by default and can sample a percentage of requests (
sample_rate) to keep overhead low. - Keep sensitive data out of traces. SQL text and bindings can contain personal data. Flow does not capture them unless you turn on
capture_sql/capture_bindings. - Re-measure after every change. A fix that moves the bottleneck elsewhere is still progress, but you only know if you measure again.
Limitations of request traces
A single-request trace answers "where did this request spend its time". It does not answer questions that span several processes: work pushed to a queue, calls across several services, or contention that only appears under load. Flow's Laravel adapter covers the single request today; queue tracing and distributed tracing are not supported yet. For load-related problems, pair traces with load testing and your database's own slow-query log.
Summary
- Do not optimise until a measurement names the slow step.
- Read each step as a share of the total request time.
- Look for repetition (N+1) when no single step dominates.
- Measure warm requests, in the environment that is slow, without capturing sensitive data.