Application performance engineering
Laravel Performance & Scaling
Applications that hold under load - measured from production rather than a laptop, fixed at the cause, and defended by budgets in CI so the numbers stay fixed.
Performance work has a predictable ending. A sprint goes into optimisation, the graphs come down, and a month and a half later they are sitting exactly where they started. The fixes were not wrong. Nothing was holding them in place.
How an engagement runs on Laravel performance work
Measure first, from production. Real traffic, sampled, segmented by endpoint. A p95 of 2.4 seconds on the order listing is an actionable statement; "the app feels slow" is not, and neither is a profile taken on a developer machine against a seeded database.
Find the cause, not the symptom. A slow endpoint in Laravel is nearly always one of five things: the query count, a single query with no usable index, work that should have been queued, a third-party call in the request path, or serialisation of far more data than the response needs. We trace each one to the line that produced it.
Fix at the right level. Moving work into a job, adding the index the query plan actually wants, eager-loading the relation, replacing a collection pipeline with an aggregate, caching a value whose cost genuinely justifies the invalidation problem it creates. These are the changes that hold.
Defend the result. A budget in CI: a query-count assertion on the endpoints that matter, and a failing build for the pull request that exceeds it. The budget is the deliverable; the graph is a side effect.
Why the graph comes back
Because a graph is an outcome and nobody owns an outcome. A budget is a number in a test, checked by a machine, on every pull request, and it fails the build of whoever exceeded it while they still remember what they changed.
That is why most clients never need us a second time for this. Optimising without a budget is borrowing against the next quarter: the same relation quietly loses its eager load, the same report gains one more column that turns out to be an accessor with a query behind it, and the regression arrives one reasonable commit at a time, from people with no way of knowing.
Where the time actually goes in a Laravel request
Ranked by how often it is the answer:
The database. Usually the query count rather than any single query - which is why it is its own engagement when it is the whole problem rather than part of one.
Work that should not be in the request. A PDF rendered, an image resized, a webhook delivered, a report generated. Every one of those belongs in a queue, and moving them is usually the single largest improvement available.
A third party in the critical path. An address lookup, a tax service, a payment provider's status call. Their latency is your latency, their outage is your outage, and neither is visible in your own profiling until you look for it.
Serialisation. An endpoint returning two hundred fields because the resource was written as "everything on the model", hydrating relations to produce a list of names.
Cache used as a plaster. Caching is not free - it buys speed with staleness, and an application with a dozen unrelated cache keys and no invalidation strategy has traded a performance problem for a correctness one.
Scaling, once it is efficient
Only then is infrastructure the answer, and the order matters: an inefficient application scaled horizontally is an inefficient application you pay for several times.
When it is warranted, the work is read replicas with the routing decided explicitly rather than globally, session and cache stores that are not the primary database, queue workers scaled separately from web capacity because they fail differently, and a deployment that does not drop in-flight work. Octane where the measurement says framework boot is actually a meaningful share of the request, which is less often than its reputation suggests.
Where Laravel's own conveniences cost you measurably
The framework is pleasant to write in, and a handful of the things that make it pleasant have a cost that only appears at volume. None of this is an argument against using them - it is knowing which ones to look at when the numbers go wrong.
A collection where a query belonged. Model::all() followed by a filter
loads every row into memory to discard most of them. At ten thousand rows it is
a slow endpoint; at a million it is the process being killed. chunk and
cursor exist for the cases that genuinely need every row.
An accessor with a query behind it. A computed attribute that reaches for a relation is invisible in the model and devastating in a loop - a list of two hundred rows calling it once each is two hundred queries that no eager load will fix, because the query is inside the accessor rather than in the relationship.
Model events in a bulk operation. update() on a query builder skips
events entirely, which is a bug when something depended on them; iterating and
calling save() fires them all, which is the right behaviour and two thousand
queries. Choosing deliberately, and writing down which one this operation
needs, is the whole fix.
Selecting everything. with('orders') on a listing that shows a count
loads every order to count them. A withCount, or a select of the columns the
view uses, turns a payload measured in megabytes into one measured in
kilobytes - and serialisation of data nothing displays is a surprisingly
common share of a slow response.
The Laravel report that has to be fast and cannot be cached
Some figures cannot be stale and cannot be computed on demand, and that combination is where most dashboards fall over.
The usual shape is a screen that aggregates across several large tables, is opened by twenty people each morning, and has to reflect what happened overnight. Computing it per request means a thirty-second query running twenty times. Caching it for an hour means somebody makes a decision on a figure that is wrong, which is worse than slow.
What works is pre-aggregation into its own table, maintained incrementally rather than recomputed. Each period's totals are written once when the period is complete, the current period is derived from a much smaller range, and the screen reads rows rather than scanning history. The expensive work happens on a schedule, under nobody's attention, and the dashboard becomes a lookup.
Incremental is the part that takes judgement, because a late-arriving record has to be able to correct a total that was already written. That means the aggregate knows which inputs it accounted for and can be re-derived for a period rather than only appended to - the same arrangement that makes a restatement explainable instead of mysterious.
A read replica helps with the remaining load and brings its own caveat worth being explicit about: replication lag means a figure read from a replica can be seconds behind the write that produced it. For a report that is usually fine. For the screen somebody lands on immediately after saving, it produces a bug report about data that disappeared.
What you receive from Laravel performance and scaling work
A prioritised findings document with each issue traced to its cause and sized by effort, the implemented fixes as reviewable pull requests, the CI budget configuration, and a before/after comparison against production traffic once the change has been live long enough to mean something.
Where the ceiling turns out to be the architecture itself - which happens - the findings say so, and put a number on the alternative beside it. That is a conversation to have in week one, not in the closing summary.
