Database engineering
Database & Eloquent Engineering
The query work behind a Laravel application that has outgrown its schema - N+1 elimination, indexing from real query plans, and migrations that do not lock a live table.
Almost every Laravel application that becomes slow becomes slow in the database, and almost every one of those was fine in development. A page that runs eleven queries against forty rows runs eleven queries against four million rows the same way, and only one of those is survivable.
Where the time actually goes
Across the engagements we run, the causes cluster into a short list.
An N+1 that only appears in production. The index page eager-loads its relation. The detail page does not, because it only ever loads one record - and then somebody reuses the detail component inside a loop. The query count goes from two to two hundred and nothing in the diff looks wrong.
A relation loaded inside an accessor. getFullAddressAttribute() touches
$this->country, the accessor is called during serialisation, and the
application issues one query per row while looking like it is formatting a
string.
An index that exists and is not used. A column wrapped in a function, an implicit type conversion between a string column and an integer parameter, a leading wildcard on a LIKE. The index is there, EXPLAIN says it is not being read, and everyone trusts the schema instead of the query plan.
Aggregation done in PHP. The collection pipeline is expressive enough that
->get()->groupBy()->map() reads better than the SQL would, so a hundred
thousand rows are hydrated into models to produce six numbers.
A migration that locked a live table. Adding an index, adding a column with a default, or changing a column type, on a table large enough that the lock outlasts the request timeout. The deploy succeeded; the site was down.
How we work
Measure first, from the real system. Slow query logs, the query count per endpoint, and EXPLAIN on the queries that matter. Not a profiler on a laptop with a seeded database, which reliably tells you about a problem you do not have.
Fix the cause rather than the symptom. A cache in front of a bad query is a way of paying for the bad query less often. Sometimes that is the right call and we will say so; more often the query wants an index, a join, or to not be issued at run time at all.
Make the fix permanent. Lazy loading disabled outside production, so the N+1 you just removed cannot be reintroduced quietly. A query-count assertion on the endpoints that matter, so a future eager-load removal fails a test rather than a support ticket. Both are a few lines and they are the difference between a fix and a fix that stays.
Schema work on a system that cannot stop
Most of what we are asked to fix is not a query but a shape: a status column that should be a state table, a polymorphic relation that made two rows impossible to constrain, an audit log growing without a retention policy, a table that is doing three jobs because splitting it seemed expensive two years ago.
That work is done in steps that are individually safe:
- Add the new structure alongside the old one, with nothing reading it.
- Backfill in batches, on a queue, with progress that survives a deploy.
- Write to both, read from the old, and reconcile until the difference is zero.
- Switch reads. Wait. Then stop writing the old one.
- Drop it, in a separate release, once nothing has touched it for a week.
It is more steps than a single migration and it is the difference between a schema change and a scheduled outage.
What you receive
A findings document with each issue traced to the query or the migration that causes it and sized by effort, the fixes as reviewable pull requests, the index changes as migrations that are safe to run on your data volume, and the CI assertions that stop the regressions coming back.
Where the answer is structural rather than a query rewrite, the findings say so and put both numbers side by side: what changing it costs, and what leaving it costs over the next year. You should be reading that in the first week rather than hearing it in a closing call.
Before starting, people usually want to read two things: what a locking schema change costs on a live table, and where MySQL and Postgres actually differ for a Laravel application. If the slow thing turns out to be the request path and not the query, performance is the neighbouring engagement.
