My phone buzzed at 2:14 in the morning. I did not recognize the number, and I almost let it ring out, until I remembered that a friend had passed my contact to a store owner earlier that day, telling her to call me directly if things got worse overnight. It clearly had.
Her voice was shaking, the voice of someone who does not normally call a stranger past midnight. I could tell right away this was not a small problem.
"Orders have stopped coming in," she said. "I do not know what is happening. I thought maybe it was just a slow night, but it has been hours."
She told me she had noticed the order count on her dashboard looked unusually quiet earlier that evening, but she brushed it off, thinking people were simply not shopping that night. It was only when she got curious and tried to open the products page herself that she realized something was badly broken. The page spun. And spun. She waited. Almost five minutes later, it finally loaded.
Five minutes. On a products page. For a store that normally loaded in under two seconds.
I told her to give me fifteen minutes and hung up, already reaching for my laptop before the call had even fully ended.
When Five Minutes Feels Like Forever
If you have ever run an online store, you know that every second a page takes to load is a second closer to losing a customer. Studies on ecommerce behavior have shown for years that shoppers abandon pages that take more than a few seconds to load. A five minute load time is not slow. It is effectively down. To a customer scrolling on their phone during a lunch break or a late night browsing session, a page that will not load is a page that does not exist.
And for her, this was not a small inconvenience. This was her income. Every minute that products page stayed broken was a minute her store was invisible to real buyers, even though the site was technically still online.
She sent over temporary server access within minutes, no questions asked, she just wanted it fixed. I logged in for the first time ever on this codebase, and since I had no history with it and no time to read through it line by line, I dropped in Laravel Telescope right there on the spot to get real visibility into what was actually happening under the hood. Within seconds of loading the products page myself, I understood why she had been sitting there watching a spinner for five minutes.
The query log was flooded. Not with ten queries. Not with fifty. Thousands of queries were firing for a single page load.
Digging Into the Code
My first instinct was that maybe the server had run out of memory, or a background job had somehow locked up the database, or there had been a bad deployment. But the query count told a different story. This was not a server problem. This was a code problem, and it had probably been quietly getting worse for weeks.
I opened the controller responsible for rendering the products page. On the surface, the code looked completely normal. Something like this.
The controller fetched all active products, looped through them, and for each product pulled its images, its category name, and its average review rating to display on the storefront cards. Nothing about it looked dangerous. It read like clean, simple Laravel code, the kind you would write without thinking twice.
But that loop was the trap.
The Real Culprit Hiding in Plain Sight
Here is the part that catches even experienced developers off guard. Eloquent, the ORM that powers database interactions in Laravel, is beautifully expressive. You can write $product->images, $product->category, $product->reviews and it just works, almost like magic. That magic is exactly what makes the N plus one problem so easy to miss.
When you fetch a list of products with a single query, that part is efficient. The trouble starts the moment you loop through those products and access a relationship on each one individually, like calling $product->images inside a foreach loop. Every single call like that fires its own separate database query.
So if her store had 40 products showing on a page, and each product needed its images, its category, and its reviews loaded individually, that is not 40 extra queries. That is potentially 120 or more, on top of the original query just to fetch the products. As her catalog had grown over the months, from a modest 40 products to well over 500, that number had grown right alongside it. What might have been a mildly inefficient page at launch had quietly become a page firing north of 1,500 individual database queries every time someone tried to browse her store.
Nobody had written bad code on purpose. The original implementation worked fine when the catalog was small. It is a mistake I have seen in projects far beyond hers, and one I actually cover in detail in my book, because it is one of the most common and most dangerous performance traps in modern web applications. It rarely announces itself with an error. It just gets slower, and slower, until one night it stops being usable altogether.
Finding the Exact Breaking Point
Before touching anything, I wanted to be certain I understood the full scope of the problem, not just guess at it. I used Telescope's query view to trace exactly where the flood of queries was originating from, and confirmed it through Laravel Debugbar locally. Both pointed to the same three relationships being lazy loaded inside the products loop, images, category, and reviews.
I asked her if anything had changed recently on the site, a new feature, a redesign, a plugin, anything at all. She said no, nothing recent, as far as she knew. That told me this was not a fresh bug someone had just introduced. This had been building gradually as her product catalog grew. Growth, in this case, was the thing that turned a hidden inefficiency into a full blown outage. That is often how these incidents happen. The code was never technically broken. It simply was not built to survive success.
The Fix
The fix itself, once diagnosed, took far less time than the diagnosis did. This is almost always true with N plus one problems. The hard part is finding them, not fixing them.
Instead of letting each product lazily pull its own relationships one at a time, I rewrote the query to eager load everything up front using Eloquent's with() method. Rather than fetching products and then separately reaching back into the database for images, category, and reviews for every single row, the application now fetched products along with their images, category, and reviews in a small, fixed number of queries, regardless of whether the catalog had 40 products or 4,000.
I also added a review count and average rating as calculated fields directly in the same eager load, rather than looping and calculating them in PHP afterward, which shaved off even more overhead. To protect against this ever silently creeping back in, I added a safeguard in the local development environment that throws a warning whenever a lazy relationship is accessed inside a loop, so any future code like this gets caught before it ever reaches production.
The Moment It Clicked
I refreshed the products page.
It loaded instantly.
I ran it again, timing it properly this time. Consistently under one second, down from nearly five minutes. Query count dropped from over 1,500 to just six.
I called her back. It was almost 3 in the morning at this point, but she picked up on the first ring.
"Try loading your products page now," I told her.
There was a pause, then a short laugh of relief. "It is instant," she said. "Oh my goodness, it is actually instant."
By the next morning, her order numbers had already started climbing back to normal. The store had not lost customers because people stopped wanting to buy from her. It had lost customers because, for a stretch of hours, her store had quietly made it nearly impossible for anyone to buy anything at all.
What This Incident Actually Taught Me
A few things stuck with me long after that night.
The first is that performance problems rarely arrive with a warning label. Nobody gets an alert that says a page is about to become five times slower than it was last month. It happens gradually, hidden behind code that looks perfectly reasonable, until growth or traffic finally exposes it all at once.
The second is that monitoring is not optional once real money is involved. Her store did not have query level monitoring in place before that night. It does now. Tools like Laravel Telescope in staging, or a proper application performance monitoring service in production, would have flagged this problem weeks before it became an emergency phone call.
The third, and maybe the most important, is that clean looking code is not the same as efficient code. The controller that caused all of this looked simple and readable. That is exactly why it slipped through. N plus one issues are sneaky precisely because they do not look like bugs. They look like normal, everyday Eloquent usage.
If You Are Running Laravel in Production, Check These Things
If you maintain a Laravel application that touches real revenue, a few habits can save you from a night like this one.
Turn on Laravel Telescope or Debugbar in a staging environment and actually watch the query count on your busiest pages, not just once, but as your data grows.
Get comfortable with eager loading using with() any time you are looping through a collection and accessing a relationship inside that loop.
Set a mental or literal query budget for key pages. If your products page or dashboard is firing more than a handful of queries, that is worth investigating before it becomes a crisis.
Test your critical pages with realistic data volume, not just the 10 sample products you used during development. Problems like this one hide comfortably at small scale and only show their teeth once your catalog, your orders, or your users actually grow.
And finally, have someone you can call in a genuine emergency, and make sure your systems are set up so that when that call comes, you can actually diagnose the problem quickly instead of guessing in the dark.
Her store survived that night because the fix, once found, was straightforward. Not every story like this ends that cleanly, and this is far from the only crime scene I have walked into. Some cost companies their data. Some cost them their reputation. Most of them, like this one, hide quietly inside code that looks completely normal until the night it does not.
That is exactly why I wrote Code Crimes, Security and Performance Mistakes in Modern Code. It walks through real mistakes like this one across Python, PHP, and JavaScript, the kind that quietly cost teams performance, money, and security, and it breaks down exactly how each one shows up, what it actually costs, and how to fix it the professional way, before it becomes your own 2 AM phone call.
You can read more about the book here, or go straight to Amazon to grab a copy.
Top comments (1)
Some comments may only be visible to logged-in visitors. Sign in to view all comments.