Laravel backend development: where a project breaks under growth
Laravel backend development rarely breaks at launch — the first symptoms appear when the growth in the number of requests stops being linear. Individual pages start responding more slowly, then response time under load spikes at peaks, and the cache no longer masks the problem.
The backend bottleneck is almost always the same: the application hits the database and the order inside the code, not the number of servers.
The most common cause is heavy database queries: selects without indexes, calls inside loops instead of eager loading relationships, aggregates over the entire table on every page view.
Under load, locks are added to them — a transaction left open during an external call holds rows and queues the remaining requests.
The second failure point is duplicated logic in controllers. The same status or cost calculation lives in several methods, gradually diverges in behavior, and any edit becomes a regression that is caught in production.
Separately, background job failures manifest: the queue grows faster than workers process it, retries hit the same locked rows, and a stuck job silently holds a query and slows down the rest.
The symptoms are interconnected: a slow query in a controller hits both the web response and the queue job. Below we discuss how we find such places and what we change — with measurements before and after.
What changes after rework: measurable effects
Reducing response time
The main gain comes from restructuring data handling: heavy queries are replaced by cache and prepared views, table relationships are rewritten so they avoid full scans.
We take measurements before and after on the same load profile — on Laravel it is visible where time goes into queries and where into intermediate layers. Typical page response time is reduced by multiples, not percentages, and this is recorded in charts in the report.
Predictability at peaks and queue processing
Mailings, exports and recalculations leave the user request and go into queues, executed by workers with a concurrency limit. Long tasks no longer hold the connection or take memory from the pages serving people.
Resource consumption becomes even — the chart shows a plateau instead of a sawtooth, and the number of concurrent operations is set in advance and does not grow with traffic. A peak load no longer turns into an outage.
Eliminating the class of failures caused by database degradation
The main cause of crashes is not a code error but an overloaded database: connections run out, queries wait and time out. We limit the connection pool, separate reads and writes, set timeouts and retries for idempotent operations, and move heavy reports to a separate environment.
Failures caused by degradation go away as a class: monitoring shows isolated events instead of regular incidents.
Work formats: optimization, rewriting, extracting services
The work format is chosen not by codebase size but by the bottleneck profile: where exactly the degradation is — in database queries, in overgrown controller code, or in one module that drags the whole monolith with it. Below are three real paths and their limitations.
| Option | When it is suitable | Limitations |
|---|---|---|
| Optimizing existing code | Local slowdown: heavy queries, duplicate queries, no cache | Does not help if the bottleneck is a shared resource |
| Rewriting the data access layer | Query logic has spread across controllers, models have overgrown | Tests are needed before work starts, otherwise there is no safe rollback |
| Extracting a loaded module into a separate service | One area handles traffic and requires its own scaling | Network interaction appears, plus separate builds and observability |
Code optimization and the data access layer
Optimizing existing code comes first when the problems are local: we remove duplicate queries, add indexes, cache heavy responses. This path is the fastest in time to result and does not change the application structure — but it will not help if the bottleneck is architectural.
When data access is smeared across controllers and models, it is cheaper to rewrite the data access layer: gather queries into repositories, bring queries to a unified form and cover them with tests. This is more work, but the next surge in load will no longer require rewriting the logic again.
Extracting a module and refactoring without downtime
Extracting a loaded module is justified when an area requires its own scaling and its own release cycle. The price is more complex operations: network calls, separate builds, logs and deployment, handling partial failures.
We perform refactoring without downtime behind a feature flag: the new code branch is enabled by a flag for part of the traffic, the old one remains for instant rollback.
We fix the format after measuring the load profile — describe the task, and we will return with an estimate and a plan.
Work stages: from bottleneck audit to handover to the team
Work begins not with rewriting but with measurement. Without "before" numbers, any change is a matter of faith: first we record the system state, then we touch the code.
- Audit of the Laravel codebase. Duplicate queries, database calls inside loops, missing indexes and places where a connection is held longer than needed.
- Performance metrics capture. We profile key scenarios: response time, number of queries per operation, memory consumption, share of time in the database and external services. This is the baseline for before-and-after comparison.
- Interaction diagram. Application, cache, queue, database and external services on one diagram — it is visible where time is lost and which node's failure drags the others down.
- Change plan. Priorities by effect-to-risk ratio: first fixes that give gains without rewriting, then structural ones.
- Configuration changes and targeted optimization. Connection pool, indexes, caching of repeated computations, moving heavy operations to the background queue.
- Load testing. Repeat run of the same scenarios with the same metrics, comparison of results and system behavior when an individual node fails.
- Handover to the team. Documentation, release procedure, metric dashboards and a debrief with your engineers — the system remains manageable without us.
Project artifacts: data schemas, procedures, documentation
A finished backend is not only working code but also a set of artifacts by which the system can be deployed, changed and fixed without the authors.
We hand them over in the repository next to the source code, in the same format as the code, so that edits go through common review and the document does not diverge from reality a month later.
- Service diagram — which processes start, how requests flow, where the cache, queue and database are, which external providers the application depends on and in what order the environment is assembled from scratch.
- Data schema — tables, relationships, indexes and deliberate denormalizations with an explanation of why they were made; next to them are migrations showing the database transition at each release.
- Queue description — which jobs go to the background, how many attempts and with what interval they are given, where idempotency is required, how to find a stuck job and safely restart it.
- Access rules — roles and permissions in the admin interface and API, where keys are stored, how they are changed, who and by what procedure gets access to the production environment.
- Deployment procedure — release build order, migration run, cache warmup, post-release checks and step-by-step rollback to the previous version if behavior diverges from the expected.
- Support documentation — typical failures and their symptoms, which metrics and logs to look at first, which alerts to react to immediately and which to handle during business hours.
A separate item is the description of environments: variables, differences between the test and production environments, secrets moved out of the repository.
Together this set reduces onboarding of a new engineer to days and removes dependence on the Laravel backend developers who led the project.
Case study: a Laravel project under growing load, metrics before and after
The project is an order processing service: Laravel behind a reverse proxy, database and queue in separate environments.
At a peak of up to 900 concurrent requests, response time grew from 0.4 to 3.5 seconds, the share of failed requests reached 12%, and queue worker crashes caused up to 40 minutes of downtime per day. Some tasks died by timeout, and clients saw empty lists instead of order history.
Profiling showed three bottlenecks. One order card took up to 300 database queries — related entities were pulled one by one. Reports and mailings were calculated synchronously in the same process as web requests and blocked the acceptance of new orders. The configuration and route cache was not built, and every request parsed the files anew.
What was changed in code and configuration
Relationships were switched to eager loading, composite indexes were added for the actual filtering conditions, and aggregates were moved to cache with invalidation on model events.
Heavy operations went to a queue with separate workers with attempt limits and retries for failed jobs. The application was launched in several instances, sessions and cache were moved to shared storage, and static assets to an external delivery layer.
A month later, on the same peak profile: response time — 0.3 seconds, error rate — 0.4%, unplanned downtime — zero, planned maintenance window — a few minutes.
The environment withstood a threefold growth in concurrent requests without new servers, and further scaling proceeded without code changes.
How is application caching different from database caching?
These are two different levels of responsibility, not alternatives. The application cache stores a ready result next to the code: an assembled response, an aggregate from several queries.
Database-side caching speeds up data access — the query plan, a materialized view, index pages. The difference is visible when the data changes.
| Level | What it stores | When it gives an effect | When it harms |
|---|---|---|---|
| Application cache | Ready computation results | Heavy aggregates, repeated reads, external calls | Multiple instances with different data; frequent record edits |
| Database-side cache | Query plans, views, indexes | Same-type frequent reads | Transactional writes: maintenance costs more than the query itself |
Cache invalidation decides. The database resets its cache at the transaction level — stale data lives for fractions of a second.
The application does not provide such a guarantee: with multiple instances, keys are invalidated manually, and a missed code branch returns stale data to the client.
The choice of caching level is dictated by the load profile. If time goes into computations and response assembly — we cache in the application: in Laravel this is a separate layer over the storage.
If time goes into waiting for a database query — we add indexes, views and read replicas without changing the application logic.
Technical concerns: queues, migrations, regression
Concerns usually surface at the project handover stage or the first release. Five questions that are asked most often, with short practical answers.
Do long mailings and exports block the interface?
No. Everything lasting longer than a couple of seconds goes to the queue and is processed by background workers. The user gets a response immediately, and the started task is not lost when the service restarts.
Can the database schema be updated without service downtime?
Yes. Migrations proceed in safe steps: first new fields and indexes, then data transfer in batches, and only then switching reads and writes. The old application version works all this time.
How can we be sure the improvement did not break what works?
Key scenarios — payment, login, checkout — are covered by automated tests and run before each deployment. A change ships only on a green run, and we check critical paths on a copy of the production database.
Who has access to the database and personal data?
Access is granted by roles and only for the current task; every connection is logged. Sensitive fields are encrypted, copies are stored separately and regularly verified by restore.
What if we do not have our own development team?
We run the project so that it can be handed over: data schemas, deployment instructions, environment descriptions and access policies. The code is stored in a shared version control system with full history — a new contractor does not need archaeology.
Below are the remaining questions worth closing before work starts.Let's discuss your task: where to start and what will be needed
To return with an estimate and a work plan, rather than general wording, engineers need access to the real picture: metrics, logs and a description of peak loads.
The more specific the inputs, the more accurate the composition of stages and the order of work — without "we'll clarify as we go."
What to send for an estimate:
- access to application metrics: response time charts, error rate, database load over recent weeks;
- description of peaks — when they occur, how many times the load grows, which area stops responding first;
- current configurations: web server, queue, cache settings, number of database connections;
- deployment diagram: how many servers, how traffic is distributed, where sessions and files live;
- list of integrations and external services the backend calls;
- access to the test environment or repository — if needed under a confidentiality agreement;
- list of user complaints with dates and times to link them with events in the logs.
The first conversation with an engineer takes about an hour: we analyze bottlenecks, clarify constraints on deadlines and compatibility, and outline priorities. As a result, you get an estimate and a work plan with a sequence of stages and control points at each iteration.
Describe the task — we will return with an estimate and a plan.







