[Field-Proven Complete Guide] Laravel Performance Optimization — Finding the Root Cause of Slowness, N+1 Fixes, Caching/HTTP Caching, Queueing, DB Indexes, Redis, Front-End Optimization, and an Accessible “Fast” Experience
What you’ll learn (key points)
- A practical workflow to eliminate slowdowns via measure → hypothesize → improve → re-measure
- How to identify common bottlenecks: N+1, unnecessary SELECTs, heavy aggregation, slow external APIs, slow I/O, etc.
- Eloquent optimization (
with/withCount/select/chunk/cursor) and DB index design - Caching (app/query/view/config), Redis, and HTTP caching (ETag/304)
- Queueing and async processing (mail/images/PDFs/aggregations), Horizon monitoring
- Front-end and delivery optimization (compression, HTTP/2, CDN, image optimization)
- An accessible “fast experience”: screen-reader-friendly loading, skeleton pitfalls,
prefers-reduced-motion, and how to communicate wait time
Target readers (who benefits?)
- Laravel beginner–intermediate engineers: want to reproduce slowness causes and accumulate improvements
- Tech leads / ops: want solid measurement and monitoring to stop regressions before deployment
- Designers / QA / accessibility: want loading UI that “anyone can understand”
Accessibility level: ★★★★★
Includes screen-reader-friendly loading (
aria-busy/role="status"), progress not relying on color alone, reduced motion, and keyboard-friendly flows that prevent getting lost during waits.
1. Introduction: Optimization Is Not “Gut Feeling,” It’s Measurement
Performance optimization isn’t about making things fast for its own sake—it’s a means to reduce user wait time, lower server costs, and prevent incidents. The quickest way to real results is to avoid guessing and repeatedly run measure → improve → re-measure.
Laravel is convenient, which also means it can become slow if you do nothing. But the upside is that the typical bottlenecks are well-known—if you eliminate them in order, you can make the app reliably faster.
2. Measure First: Identify What’s Slow
2.1 Define Your Goal (SLO) Up Front
Examples:
- p95 of key pages under 300ms
- p95 of APIs under 200ms
- 5xx rate under 0.1%
If you don’t decide which screens matter, optimization never ends.
2.2 Core Measurement Points
- App: request duration, number of SQL queries, SQL time, external HTTP time, queue delays
- DB: slow queries, locks, index usage
- Infra: CPU, memory, I/O, network, cache hit ratio
- UX: TTFB, LCP, CLS, INP (Web Vitals)
2.3 Practical Tools on the Laravel Side
- Telescope: visualize requests/SQL/exceptions in dev and validation
- Logs: structured logging (
trace_id+ timings) - APM: Sentry / Datadog / New Relic (very strong if you can adopt one)
3. A Map of Typical Bottlenecks (The Order That Usually Pays Off First)
Listed in the order that tends to deliver results fastest in real projects:
- N+1 (too many SQL queries)
- Fetching unnecessary columns/rows (SELECT bloat, no paging)
- Heavy aggregations (COUNT / GROUP BY on every request)
- Slow/unstable external APIs
- Heavy work executed synchronously (image/PDF generation, etc.)
- No cache / ineffective cache
- Poor DB indexes
- Front-end delivery issues (no compression, heavy images, no CDN)
4. N+1 Fixes: The Absolute Basics of Eloquent
4.1 N+1 Example (Bad)
$posts = Post::latest()->take(20)->get();
foreach ($posts as $post) {
echo $post->user->name; // likely triggers 20 extra queries
}
4.2 Eager Loading with with (Good)
$posts = Post::with('user')->latest()->take(20)->get();
4.3 Counts via withCount
$posts = Post::withCount('comments')->latest()->paginate(20);
4.4 Also Minimize Columns with select
$posts = Post::query()
->select(['id','user_id','title','created_at'])
->with(['user:id,name'])
->latest()
->paginate(20);
Notes
- Fewer columns = less memory + less transfer.
withis not magical. Overusing it can slow things down, so add it step-by-step starting from key screens.
5. DB Query Optimization: Paging, Indexes, and Slow Queries
5.1 Paging Is Mandatory
Returning “all rows” in a list is not only slow; it can also blow memory.
Use paginate() or simplePaginate() by default.
5.2 Index Design Rules of Thumb
- Use composite indexes that match your WHERE/ORDER BY combinations
- If you filter by
tenant_id, include it first, e.g.(tenant_id, created_at) - Design indexes in the order of “most common conditions”
Example:
WHERE status = ? AND created_at >= ? ORDER BY created_at DESC
→ consider(status, created_at)
5.3 Reading EXPLAIN (Simplified)
- If
type=ALL(full table scan) appears, treat it as a red flag - Fix queries with extremely large
rowsestimates first Using filesortisn’t always bad, but if it’s frequent, investigate
6. Large Data Processing: chunk / cursor / lazy
6.1 chunk
User::where('active', true)->chunkById(1000, function($users){
foreach ($users as $u) { /* work */ }
});
6.2 cursor (Memory-friendly)
foreach (User::where('active', true)->cursor() as $u) {
// processes one by one, tends to use less memory
}
6.3 Caveats
cursor()doesn’t run one SQL per row; it streams via an iterator. But be careful with relationships (N+1 can reappear easily).- Bulk processing generally pairs well with queueing.
7. Caching: Biggest Impact for the Least Effort
7.1 Start with “High Cost, Low Change” Data
- Top-page rankings
- Navigation category lists
- Dashboard aggregates
- Configuration values (feature flags, plan limits)
$items = Cache::remember('top:popular', 300, function(){
return Product::orderByDesc('popularity')->take(20)->get();
});
7.2 Cache Key Design
- If data depends on locale/tenant/user, include them in the key
- Example:
t:{tenant_id}:home:popular:ja
7.3 Tagged Cache (Store-dependent)
It lets you invalidate related keys at once, which makes operations easier.
8. HTTP Caching: Save Bandwidth with ETag/304
For APIs and lists that rarely change, conditional requests are effective.
- No change → 304
- Changed → 200 + ETag
$etag = sha1($updatedAt.$id);
if (request()->header('If-None-Match') === $etag) {
return response()->noContent(304)->header('ETag',$etag);
}
return response()->json($data)->header('ETag',$etag);
9. Queueing: Don’t Make Users Wait for Heavy Work
9.1 Typical Async Candidates
- Sending email
- PDF/image generation
- External API synchronization
- Large aggregations
- Audit logs and search index updates
9.2 UI Presentation (Accessible)
Instead of blocking synchronously, show users:
- “Started”
- “In progress”
- “Completed (download here)”
<div role="status" aria-live="polite" id="job-status">
Export started. You’ll be notified when it’s complete.
</div>
10. Redis: Faster Sessions / Cache / Queues
- Redis sessions reduce I/O and make horizontal scaling easier
- Cache via Redis is stable and fast
- With Horizon, you can visualize queue health
Note
- Redis is fast, but sloppy key design can waste memory. Define TTLs and caps.
11. Front-End Optimization: If Only the Server Is Fast, It Still Won’t Feel Fast
11.1 Images
- Optimize first (WebP/AVIF, proper sizing, lazy loading)
- Specify
width/heightto reduce layout shifts
11.2 Compression
- Enable gzip/brotli
- Put static assets on a CDN
11.3 JS/CSS
- Reduce unused JS
- Prioritize critical CSS
- Respect
prefers-reduced-motionand tone down animation
12. An Accessible “Fast Experience”: Communicating Wait Time
Just as important as making things faster is avoiding user anxiety while they wait.
12.1 Explicit Loading State
aria-busy="true"on the updating region- Announce progress/completion with
role="status" - Use skeletons as decoration, and provide a text alternative too
<section id="result" aria-busy="true" aria-live="polite">
<p class="sr-only">Loading.</p>
{{-- skeleton UI --}}
</section>
12.2 Retry Paths
On load failure, offer choices like:
- “Try again”
- “Try later”
- “Show a lightweight version”
This reduces dead ends.
13. Minimal Improvement Plan (Recommended Phased Adoption)
- Add measurement to key screens (SQL count/time, external API time)
- Fix N+1 (
with/withCount) - Convert lists to
select+ paging - Cache high-cost aggregates for 5 minutes
- Queue image/PDF/mail work
- Add DB indexes
- Add HTTP caching / ETag
- Add monitoring to detect regressions
14. Common Pitfalls and How to Avoid Them
- Overusing
with()and making things heavier- Fix: eager load only critical relationships, and limit columns
- Cache becomes “too stale” and hurts
- Fix: shorter TTL + invalidate on updates, or create “materialized” summary tables
paginate()COUNT becomes expensive- Fix: use
simplePaginate()where acceptable, or materialize aggregates
- Fix: use
- Calling external APIs synchronously forever
- Fix: timeout/retry/fallback; async if possible
- Skeleton UI alone makes the state unclear
- Fix: pair with
role="status"and a short message
- Fix: pair with
- Infinite scroll causes users to get lost
- Fix: a “Load more” button + screen-reader announcements
15. Checklist (For Handout)
Measurement
- [ ] Visualize p95, SQL count, external API time, and queue delay on key screens
- [ ] Set alerts for regressions (p95 / 5xx / latency)
DB/Eloquent
- [ ] Eliminate N+1 with
with/withCount - [ ] Minimize columns with
select - [ ] Paging for all lists
- [ ] Indexes match WHERE/ORDER patterns
Caching/Async
- [ ] Cache expensive aggregates with
Cache::remember - [ ] Include tenant/locale in cache keys
- [ ] Queue heavy work; monitor with Horizon
HTTP/Front-End
- [ ] Conditional requests with ETag/304
- [ ] gzip/brotli, CDN
- [ ] Image optimization (WebP, correct sizes, lazy)
Accessibility
- [ ] Announce loading with
aria-busyandrole="status" - [ ] Progress not dependent on color
- [ ] Retry paths on failure
- [ ] Respect
prefers-reduced-motion
16. Summary
Laravel performance improves cleanly when you measure and eliminate bottlenecks by known patterns. Start with N+1, paging, and column minimization. Then move to caching and queueing for a “don’t make users wait” design, and solidify the foundation with DB indexes and HTTP caching. And speed pairs well with accessibility: don’t just shorten waits—announce status in short text, provide retries and alternatives, and make the experience calm and usable for everyone.
Reference Links
- Laravel Official
- Performance & Web Experience
- Accessibility
