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::listenreceives aQueryExecutedevent carryingsql,bindings,timeandtoRawSql, andDB::whenQueryingForLongerThanfires 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.
| Suspect | Signal that implicates it | First instrument | Chapter |
|---|---|---|---|
| Query volume or missing index | Query count per request, or one plan scanning far more rows than it returns | DB::listen count, then EXPLAIN | 3 and 4 |
| Write contention | Latency rising only on write routes, deadlock retries, lock wait timeouts | Database lock metrics, deadlock log | 5 |
| Cache invalidation | Hit ratio falling, or eviction counters climbing under steady traffic | Redis keyspace_hits and evicted_keys | 6 |
| Queue saturation | Backlog age growing while depth looks stable | Horizon wait times, backlog age | 7 |
| Runtime and boot cost | Flat overhead on every route, including trivial ones | Profiler, OPcache status | 8 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
- Monitoring Distributed SystemsGoogle, Site Reliability Engineering, chapter 6 · 2016 · Vendor engineering · verified
- Database: Getting StartedLaravel · 2026-07-29 · Official documentation · verified
- Laravel PulseLaravel · 2026-07-29 · Official documentation · verified
- Laravel TelescopeLaravel · 2026-07-29 · Official documentation · verified
- Profiling PHP ScriptsXdebug · 2026-07-29 · Official documentation · verified