
5 Laravel Mistakes I Made (and How I Fixed Them)
When I started with Laravel, I thought I was writing clean and scalable code.
I wasn't.
Five years and a few production fires later, I have a much shorter list of things I'm proud of, and a much longer list of things I'd do differently.
Table of Contents
I'm a senior engineer with 5+ years building production systems: a healthcare platform with HL7 integrations, an eCommerce stack handling Stripe, PayPal, and Google Pay, and a few enterprise workflow tools that never made any brochure.
Laravel carried most of that weight. Most of what I know about Laravel best practices, I learned by breaking things in production first.
This isn't a "10 Laravel tips" listicle. These are five specific mistakes that cost me sleep, broke deploys, or quietly corroded an app until refactoring was the only option.
If you're past the tutorial stage, you've probably made at least one of them.
1. Fat Controllers and No Service Layer
What I did wrong
Early on, my controllers did everything: validation, business rules, payment processing, sending emails, writing audit logs, all inside store() and update().
Resource controllers felt clean enough until they didn't.
Why it hurt
On the eCommerce platform, the checkout controller eventually pushed past 600 lines.
Every payment provider (Stripe, PayPal, Google Pay) needed slightly different logic, and all of it was tangled into the same method. Writing tests was painful. Adding a provider meant editing the most dangerous file in the repo.
We had a Stripe webhook bug that took a week to track down because the same logic existed in three places, slightly out of sync.
How I fixed it
A service layer. Controllers became dumb: receive, delegate, respond. Business logic moved into single-purpose service classes (CheckoutService, PaymentGateway, OrderFulfillment), and providers became swappable through interfaces.
public function store(CheckoutRequest $request, CheckoutService $checkout)
{
$order = $checkout->process($request->validated(), auth()->user());
return new OrderResource($order);
}The controller stops caring how an order is processed. Testing went from "spin up the world" to instantiating a service with mocked dependencies.
This is the single highest-leverage change I have ever made to a Laravel codebase.
2. Treating Routes as API Documentation
What I did wrong
I shipped APIs that "worked." Inconsistent response shapes, no versioning, error messages varying per endpoint, status codes used however felt right that afternoon.
Why it hurt
Once mobile clients and partner integrations started consuming those APIs, every change broke something downstream.
On the healthcare platform, an HL7 ingestion endpoint returned { "status": "ok" } on success and { "error": "..." } on failure. The third-party LIS hitting it had to special-case both, and when we added a warnings array, two integrations silently broke.
How I fixed it
Four rules now anchor my Laravel API design:
- Versioned routes (
/api/v1/...) from day one, even with one version. - API Resources for every response. Never raw
->toArray(). - A consistent envelope:
data,meta,errors. Same shape every time. - Form Requests for every input. No exceptions.
It sounds bureaucratic. It isn't.
It's the difference between "the API changed" being a one-line note versus a five-day fire drill.
3. Ignoring Caching Until p95 Collapsed
What I did wrong
I assumed Eloquent and a healthy MySQL would carry us. They did, until traffic hit a multiplier we hadn't planned for, and our dashboard endpoint started taking 4–6 seconds.
Why it hurt
The dashboard fanned out to a dozen aggregations, most of them recomputing values that changed maybe once an hour.
Every page load did the same expensive work for every user. DB CPU sat at 80% during peak hours. We were one Black Friday away from a real incident.
How I fixed it
Redis, but with discipline. The key to real Laravel performance optimization here was being honest about what could be cached and for how long:
- Per-user data with explicit TTLs and tag-based invalidation.
- Query-level caching for expensive aggregates that don't need to be real-time.
- Cache busting on writes. Never rely on TTL alone for data that mutates.
return Cache::tags(['dashboard', "user:{$userId}"])
->remember("dashboard:{$userId}", now()->addMinutes(15), function () use ($userId) {
return $this->dashboard->compose($userId);
});Dashboard p95 dropped from ~5s to under 300ms.
The fix wasn't more hardware. It was admitting we didn't need fresh data every request.
4. Trusting the ORM Blindly
What I did wrong
Eloquent makes bad queries feel comfortable. I wrote $order->items inside a Blade loop, and Laravel happily issued one query per order.
50 orders → 51 queries. Multiply by every relationship on the page and you have a problem.
Why it hurt
On the healthcare reporting module, a "simple" patient list ran 1,200+ queries per page render.
The page worked in dev with seed data and died in staging with realistic volume. We caught it because the DB connection pool exhausted, not because anyone was profiling.
How I fixed it
with()andwithCount()everywhere a relationship is touched in a view.- Laravel Telescope and Debugbar in dev. N+1s become impossible to miss.
- For heavy reports, raw queries or the query builder. Eloquent isn't free, and "elegant code" isn't worth a 30-second response time.
- Indexes on foreign keys and on every column used in
WHERE,ORDER BY, orJOIN. Sounds obvious. Most slow Laravel apps I've inherited have missing indexes.
If you care about scalable Laravel applications, profile every page that touches more than one relationship. Always.
5. Spreading Business Logic Across Models, Observers, and Events
What I did wrong
I leaned hard on model events and observers. creating, updated, deleting: every lifecycle hook had logic in it.
Felt clever at the time.
Why it hurt
On an enterprise workflow project, saving an Order triggered an observer that fired an event that fired three listeners that mutated four other models, each with their own observers.
Tracing a single user action through the call stack became a treasure hunt.
Worse: a seeder triggered the entire chain, firing real notifications during a backfill.
How I fixed it
- Observers only for cross-cutting concerns: auditing, cache invalidation. Never core business logic.
- Domain logic stays in service classes, called explicitly. If a save needs to trigger fulfillment, the service calls fulfillment. It doesn't hide behind an event.
- Events are for genuinely decoupled side effects (emails, webhooks, async jobs). Not control flow.
If I can't read a controller and tell you everything it does, the architecture is wrong.
Key Takeaways
- Controllers route. Services do.
- APIs are contracts. Version and standardize them.
- Cache deliberately. Know your TTLs and invalidation paths.
- Profile every relationship-touching page. N+1 is invisible locally.
- Make business logic explicit. Hidden side effects compound.
Closing Thoughts
None of this came from a course. It came from production traffic, on-call pages, and code reviews where someone smarter than me asked "why is this here?"
Laravel is forgiving. You can ship a sloppy app and it will run. But the bill comes later, usually at the worst time.
The good news: every one of these mistakes has a clean fix, and most only need to be made once. The discipline is in noticing them earlier the next time.
If you're working with Laravel, you've probably made at least one of these. What was yours?