Laravel

Measure Before You Touch Anything

Profiles, traces, and a baseline that makes the next change arguable.

Chapter 1 of 1311 min readOpen access

Measure Before You Touch Anything is the chapter that makes every later chapter arguable instead of fashionable. A change you cannot defend with a before and an after is a preference, and preferences do not survive contact with next quarter's traffic.

Key takeaways

  • A baseline is a measurement you can run again on demand. If re-running it depends on somebody remembering a sequence of steps, you have an anecdote rather than a baseline.
  • Google's SRE book, chapter 6, names the four golden signals as latency, traffic, errors and saturation, and advises collecting request counts bucketed by latency rather than actual latencies.
  • The same chapter says to distinguish the latency of successful requests from the latency of failed ones, because a slow error is worse than a fast error and an average of both hides each.
  • Laravel 13 ships the hooks in the framework: DB::listen receives a QueryExecuted event carrying sql, bindings, time and toRawSql, and DB::whenQueryingForLongerThan fires on cumulative query time per request.
  • Pulse recorders default to a slow threshold of 1,000 milliseconds and Pulse samples, prefixing sampled values with a tilde, so its dashboard is a population estimate rather than a request log.

Read this beside Chapter 2, which explains what the numbers collected here actually mean, and Chapter 12, which points the same instruments at a system you are deliberately overloading. The case that a measurement nobody can re-run is not evidence at all is the whole of the evidence-before-shipping argument.

A checkout page gets slow. The team has a theory, and the theory is the ORM.

Two weeks go into replacing the heaviest Eloquent query with hand-written SQL. It ships. The page is still slow, and now one file in the codebase has no maintainer.

Then somebody opens the query log for that route and finds it issuing hundreds of queries, none of them individually slow. The theory was never tested. It was adopted.

A baseline is a measurement you can run again

A baseline is not a screenshot of a dashboard on a bad afternoon. It is a procedure with three properties, and a measurement missing any of them will not settle an argument later.

It is scripted, so that running it twice takes a command rather than a person. It names its workload, meaning which routes, at what mix, against what data volume. And it records the environment: PHP version, worker count, cache state, database size, whether the caches were warm.

That third property is the one teams skip, and it is the one that invalidates the comparison six weeks later. A faster reading on a machine with a warm OPcache and a populated Redis is not a faster application. It is a different experiment.

Write the procedure down before you collect anything. The discipline costs an hour. Skipping it costs the ability to say whether any of the next twelve chapters helped.

The four signals tell you which chapter to open next

Google's SRE book, chapter 6 on monitoring distributed systems, names four golden signals: latency, traffic, errors and saturation. That set is worth adopting here for a narrow reason, which is that each signal implicates a different half of this book.

Latency without a traffic change points at work per request: queries, hydration, render. Latency that moves with traffic points at a pool being saturated, which is Chapter 2. Errors rising alongside latency usually means something is timing out rather than slowing down, and a timeout is a queue that gave up.

Saturation is the one most Laravel teams do not collect, and it is the most diagnostic of the four. It is not CPU. It is how full the fixed pools are: FPM workers, database connections, Redis connections, queue workers.

Collect all four for every service, not just the web tier. A queue worker has latency, traffic, errors and saturation too, and Chapter 7 is largely about reading them.

An average is the wrong summary of a latency distribution

The SRE book is specific about how to collect latency: as request counts bucketed by latency, rather than as actual latencies. That phrasing rewards a second read, because it rules out the metric most applications actually emit.

A mean response time cannot show a bimodal distribution, and slow Laravel applications are almost always bimodal. Most requests hit a warm cache and a small index range. A minority miss the cache, or scan, or wait behind a lock. Those two populations average into a number that describes neither.

The same chapter adds a distinction that is easy to skip and expensive to skip. Separate the latency of successful requests from the latency of failed ones, because a slow error is worse than a fast error. An application that fails after thirty seconds and one that fails in fifty milliseconds have the same error rate and completely different user experiences.

So the minimum honest latency reading has three axes: percentile, route, and outcome. Pick the percentiles before you look at the data, so you cannot pick the flattering one afterwards.

Instrument the query layer from inside the request

Laravel 13's database documentation, fetched on 29 July 2026, documents three hooks that between them cover most of what an external tool would tell you, at no licence cost.

DB::listen receives a QueryExecuted event for every query, exposing sql, bindings, time and a toRawSql method. Logging the count and the cumulative time per request, keyed by route, is the single highest-value metric in a Laravel application. It is also the one that finds N+1 without anybody looking for it.

DB::whenQueryingForLongerThan takes a millisecond threshold and a callback, and fires when cumulative query time within one request exceeds it. That is a different question from the slow query log, which sees each query alone and therefore never sees four hundred fast ones.

db:monitor takes --databases and --max and dispatches a DatabaseBusy event when open connections exceed the threshold. That is your saturation signal for the connection pool, and Chapter 2 explains why it matters more than it looks.

The illustrative shape is two lines in a service provider:

DB::listen(fn ($q) => Metrics::observe('db.time', $q->time));
DB::whenQueryingForLongerThan(500, fn ($c) => Log::warning('slow request', ['conn' => $c->getName()]));

Pulse estimates a population, Telescope inspects one event

Laravel's two first-party observability packages answer different questions, and using either for the other's question wastes a week.

Pulse aggregates. Its documented recorders include Requests, SlowQueries, SlowJobs, SlowOutgoingRequests, CacheInteractions, Exceptions, Queues, Servers, UserJobs and UserRequests, with a default slow threshold of 1,000 milliseconds and per-pattern overrides. Two documented behaviours matter for how you read it. Pulse samples, and scales sampled values, prefixing them with a tilde. And Pulse fails silently if capture throws, so a gap in the data is not evidence of a gap in the traffic.

Telescope inspects. Its watchers cover requests, queries, models, jobs, cache, Redis, exceptions, mail, notifications and the HTTP client, one entry at a time. Its own documentation warns that without pruning the telescope_entries table accumulates records very quickly, and that APP_ENV must be production in production or the installation is publicly available.

Use Pulse to find the route. Use Telescope to understand one request on that route. Neither replaces the other.

Profile one request when the aggregate stops telling you anything

Sometimes the query count is fine, the cache is warm, and the route is still slow. That is where a profiler earns its cost, and Xdebug's is free and documented.

Xdebug's profiler documentation, fetched on 29 July 2026, sets xdebug.mode to profile and writes Cachegrind-format files named from xdebug.profiler_output_name, defaulting to cachegrind.out.%p, into xdebug.output_dir. Arm it per request with xdebug.start_with_request=trigger and a matching xdebug.trigger_value, then read the output in KCachegrind, QCachegrind or Webgrind.

Two cautions. Xdebug's own documentation warns that the amount of information generated can be enormous for complex scripts, so watch the disk. And a profiler adds overhead to everything it measures, which means you read the shape rather than the wall clock: which call tree holds the inclusive cost, and how many times a function was entered.

The call count column is usually more revealing than the time column. Four thousand calls to a model accessor is a design fact, not a timing fact.

Rank the suspects before you touch one of them

This is my ordering rather than a published finding, and I am stating it as judgement because that is what it is. In my experience the order of suspicion for a Laravel application that slowed down as it got busier runs: queries, then write contention, then cache invalidation, then queue depth, then the runtime.

SuspectSignal that implicates itFirst instrumentChapter
Query volume or missing indexQuery count per request, or one plan scanning far more rows than it returnsDB::listen count, then EXPLAIN3 and 4
Write contentionLatency rising only on write routes, deadlock retries, lock wait timeoutsDatabase lock metrics, deadlock log5
Cache invalidationHit ratio falling, or eviction counters climbing under steady trafficRedis keyspace_hits and evicted_keys6
Queue saturationBacklog age growing while depth looks stableHorizon wait times, backlog age7
Runtime and boot costFlat overhead on every route, including trivial onesProfiler, OPcache status8 and 11

The order exists because of cost and reversibility, not because of glamour. An index is cheap to add and cheap to drop. A runtime change touches every request in the system and is the hardest thing to attribute afterwards.

A worked baseline, composited and labelled

The details here are composited from ordinary production shapes rather than one system, and the shape is exact.

An admin listing is reported as slow. The first reading, taken with DB::listen counting queries per request, shows the route emitting a query count that scales with the number of rows on the page. That is a kind of reading, not a quantity: the count moves with page size.

The second reading takes the same route with eager loading added and shows a count that no longer moves with page size. Same instrument, same workload, different shape. The difference licenses one conclusion and no others: this route had an N+1, and it does not now.

What the pair does not license is a claim about response time. The queries were never slow individually, so the win is in round trips and hydration, and you would need a third reading at the response layer to say anything about latency. Say the narrow thing. The narrow thing is defensible.

The objection: we already pay for an application monitor

The usual response to this chapter is that a hosted monitoring product already reports p95 per route, so building any of this is duplicated effort. Half of that is right.

An external monitor is good at the population view: which routes are slow, when they became slow, and how often. What it rarely gives you is a re-runnable workload, and that is the thing this chapter is actually about. A dashboard tells you the state of production. A baseline tells you whether your change worked.

There is a second gap that matters in Laravel specifically. Most monitors sample, and the per-request query count is exactly the metric that sampling destroys, because the requests you most want are the rare ones. Laravel's own hooks run inside every request and cost you a callback.

So keep the monitor for the population and add the in-request instrumentation for the mechanism. They are not the same measurement, and the second one is cheap.

Chapter summary

A baseline is a measurement you can run again on demand, and it needs three properties to be worth anything: it is scripted so re-running it is a command, it names its workload as routes and mix and data volume, and it records the environment including whether the caches were warm, because a warm-cache reading compared against a cold-cache reading is two experiments rather than one. Google's SRE book, chapter 6, gives the four signals worth collecting for every tier including the workers, being latency, traffic, errors and saturation, and it is saturation, meaning how full the fixed pools are, that Laravel teams most often omit and most need. The same chapter tells you to collect request counts bucketed by latency rather than actual latencies, and to separate the latency of successes from the latency of failures, because slow Laravel applications are bimodal and a mean describes neither population. Laravel 13 ships the instrumentation: DB::listen for a QueryExecuted event with sql, bindings, time and toRawSql, DB::whenQueryingForLongerThan for cumulative per-request query time, and db:monitor --max for connection saturation. Pulse aggregates with a documented 1,000 millisecond slow threshold, samples and marks sampled values with a tilde, and fails silently on a capture error. Telescope inspects single events and needs pruning. Xdebug profiles one request into Cachegrind output, and its call counts tell you more than its timings. Rank the suspects before touching one, in the order queries, write contention, cache invalidation, queue depth, runtime, because that order tracks cost and reversibility.

Every suspect in that table is the same structural thing wearing a different costume: a queue in front of a fixed pool. Chapter 2 is Where the Queue Forms, and it is the model the remaining eleven chapters are all instances of.

Sources

  1. Monitoring Distributed SystemsGoogle, Site Reliability Engineering, chapter 6 · 2016 · Vendor engineering · verified
  2. Database: Getting StartedLaravel · 2026-07-29 · Official documentation · verified
  3. Laravel PulseLaravel · 2026-07-29 · Official documentation · verified
  4. Laravel TelescopeLaravel · 2026-07-29 · Official documentation · verified
  5. Profiling PHP ScriptsXdebug · 2026-07-29 · Official documentation · verified