Five request profiles return the same catalogue data. A sixth path accepts a background rebuild.
One profile is the control. One repeats six database calls. One recomputes a
value behind a unique cache key. One spends its time hashing in PHP. One uses a
warm cache and stays close to the control. The rebuild responds 202 Accepted
quickly while ten jobs wait behind one worker.
“The application is slow” fits the three deliberately expensive catalogue paths. “Work is delayed” fits the queue. “Add another server” addresses only one of them, and even there it is a hypothesis.
A slow request is a symptom. A Laravel capacity decision needs a workload, a latency distribution, and the resource or dependency whose work grows with that symptom.
Define the workload before opening the dashboard
The synthetic LAR-01 fixture fixes the experiment before it collects a number:
- three products and one response contract;
- three trials of fifteen measured requests;
- three warm-up requests before each trial;
- sequential execution through Laravel’s HTTP kernel;
- an explicit cold, ineffective, or warm cache state;
- one database queue worker and ten jobs; and
- a pinned PHP, Laravel, Composer lock, container, CPU count, and memory limit.
Schema creation, fixture insertion, container build, and warm-up do not count toward request duration. Every measured catalogue request must still return the same status and body.
That contract matters more than the load tool. Without it, a second run can be faster because it has less data, skips work, starts warm, or returns a different answer. The number improved. The comparison did not.
Production measurement needs the same discipline. Name the route, parameters, traffic mix, data distribution, concurrency, cache state, queue connection, and observation window before comparing a deployment or configuration change.
A percentile shows impact, not cause
The fixture retains every sample, then calculates minimum, median, p95, p99, maximum, interquartile range, and throughput with a declared nearest-rank method. It does not keep the fastest run and call that capacity.
A median answers what happened near the middle of this workload. A p95 answers what at least 95 percent of its samples did not exceed. The distance between them exposes a tail which an average can smooth away. None of those values identify why the request waited.
The clean export illustrates the problem. Some local p95 values moved far beyond their medians. The reproduction does not isolate the source of that host or container noise. Its semantic gate still passed because response behavior and the seeded signatures remained intact. Turning those milliseconds into a universal CI threshold would make environmental noise look like an application regression.
Use a latency distribution to locate user impact and select a trace or profile. Do not ask it to name the constrained resource by itself.
Query count and query time answer different questions
Laravel exposes each executed query through its database events. It can also report when cumulative query time crosses a threshold during one request.
The query-bound mode keeps the catalogue response unchanged but issues six synthetic delayed queries after its normal product query. Its observer records seven queries per request, their normalized identities, cumulative duration, and maximum duration. The control records one.
Count catches repeated cheap work. Cumulative time catches fewer expensive queries. Maximum time points toward one dominant query. Retaining only one of the three loses a different diagnosis.
Another web process does not remove those six calls. It can admit more requests which issue them. The next useful experiment is nearer the evidence: find their origin, inspect the real query plan and data distribution, then remove, combine, or reshape the repeated work. Database server load, locks, connections, and I/O remain outside this SQLite fixture and need their own production signals.
Cache state belongs beside cache hit ratio
The cache-bound modes use the same loader and return the same response.
The ineffective mode includes the sample number in its key. All 45 measured requests miss, execute the loader, and write a value nobody reuses. The stable mode primes one key during warm-up. All 45 measured requests hit it, and the loader stays out of the measured path.
Laravel’s cache events expose hits, misses, and writes. Those counts only become useful when the report also names the key family and cache state. A 95 percent hit ratio can hide one expensive key which always misses. A low ratio can be correct for keys read only once. A warm benchmark can conceal every cold start.
Adding application servers cannot repair a key with accidental cardinality. With a local cache it may create more cold stores. With a shared cache it can increase concurrent misses. The next decision concerns key ownership, invalidation, tolerated staleness, and stampede behavior—not instance count.
A fast accepted request can hide a slow queue
The queue case responds quickly because acceptance is not completion.
After ten accepted requests, the fixture records ten queued jobs and an oldest age greater than zero. One worker then drains them while Laravel’s job events record ten processed jobs, each runtime, and zero releases or failures.
Laravel’s queue documentation describes a QueueBusy event when
monitored queue depth crosses a threshold, while Horizon reports job
throughput, runtime, and failures for Redis queues. Pulse can place
slow requests, slow jobs, queues, queries, cache interaction, and server
resources in the same Laravel view.
Depth alone is incomplete. Ten jobs can be a healthy burst if workers drain twenty per second. Ten can be a growing outage if five arrive each second and workers finish four. Retain arrival rate, drain rate, oldest-job age, runtime, release, and failure outcomes.
More web processes do not drain this worker queue. They may enqueue faster. A queue-capacity decision starts with job shape, worker lifecycle, retry policy, priority, and shared database or Redis pressure.
CPU makes another process plausible, not proven
The CPU mode performs deterministic PHP work without adding queries or cache operations. Process user time rises with request duration. That correlation makes application compute the leading explanation inside the fixture.
PHP’s getrusage exposes process user and system time, and
memory_get_peak_usage exposes peak allocated memory. They do
not reveal database CPU, Redis saturation, network wait, a container’s stolen
time, or the headroom of the host running other processes.
This is the only fixture mode where another web process becomes a plausible next experiment. It is not yet a recommendation. First profile the computation. Then test representative concurrency under a declared CPU limit. Confirm that the work divides, memory remains safe, and shared dependencies retain headroom.
If one core is saturated and fifteen are idle, worker configuration may be the constraint. If every core is saturated, a cheaper algorithm may beat more hardware. If CPU is quiet while requests wait, adding compute is answering the wrong signal.
Let the constrained resource choose the next experiment
The reproduction keeps a score-free decision record:
| Signature | Leading constraint | Next experiment | Another web process? |
|---|---|---|---|
| Repeated queries | Database work per request | Trace origin and inspect the real plan | Does not remove the calls |
| Miss and write on every request | Cache key prevents reuse | Repair key and validate freshness | Does not create reuse |
| High process user time | Application computation | Profile, then test controlled concurrency | Plausible with dependency headroom |
| Fast acceptance, growing age | Queue drain or job shape | Compare arrival and drain rates | Does not drain workers |
| No changed signature | No seeded constraint | Keep measuring the representative workload | Not justified |
There is no weighted winner. Systems can exhaust CPU while also waiting on a database and accumulating jobs. Conflicting signals call for a narrower experiment, not a more impressive score.
The official Pulse documentation is useful here because it spans application and server signals while stating its own sampling and storage costs. Database-native tools, Redis metrics, infrastructure monitoring, and a request profiler still own the details Pulse cannot observe.
Change one constraint and repeat the same profile
Before buying capacity, finish this record:
workload and user impact:
changed signal:
suspected constraint:
one next experiment or repair:
why another web process would or would not help:
missing production evidence:
condition which reverses this decision:Change one seam. Preserve response behavior. Repeat the same warm-up, samples, trials, and observer set. If the expected signal does not move, the diagnosis was wrong or the measurement could not see the constraint.
The reproduction retains 235 request samples, 20 rejected evidence mutations,
seven official-source responses, six decision records, and a clean export of
commit 45883b6adc4bb68c974dcea94abe3928eaef87eb. It proves that the five
synthetic modes reach different observers. It does not prove Brian’s
production bottleneck, service level, cost, dependency capacity, or response
to horizontal scaling.
That last limitation is the point. A server is a treatment. Measure enough to name the constraint before prescribing it.