【Practical Complete Guide】Laravel Performance Optimization & Observability — Caching, DB Design, Job Splitting, Octane, Metrics, and Accessible Loading States
What you’ll learn (key points)
- Designing caches (Config/Route/View/Query) and HTTP caching
- Eliminating Eloquent N+1s, index design, optimizing aggregation & pagination
- Job splitting, throttling, de-duplication, idempotent handling
- Octane (Swoole/RoadRunner), OPcache, and optimal static-asset delivery
- Monitoring with Horizon, Telescope, structured logs, and metrics (latency/error rate)
- Accessible loading UI, progressive enhancement, progress indications not reliant on color
Intended readers (who benefits?)
- Laravel beginner–intermediate engineers: raise speed and stability step by step
- Tech leads/architects: standardize operations-ready design with observability
- SRE/QA: run metric-driven performance testing and regression detection
- Designers/writers: craft accessible loading displays and skeleton UIs
1. Guiding principles: “Reduce waste first,” then “get faster by design”
If you optimize in the wrong order, only complexity grows.
- Measure: identify slow requests/queries/jobs.
- Reduce: N+1s, unnecessary queries, wasted rendering.
- Cache: data, templates, HTTP.
- Asynchronize: push heavy work to jobs.
- Harden the platform: OPcache, Octane, CDN.
- Observe: detect regressions and enable rollbacks.
Below are concrete steps for safely stacking improvements.
2. Caching strategy: maximize hit surface, control invalidation
2.1 Start with built-in caches
php artisan config:cache
php artisan route:cache
php artisan view:cache
- Cut startup overhead via compiled config/routes/Blade caches.
- In production, OPcache is a given. Have deploy/restart refresh caches automatically.
2.2 Data cache (example in Repository layer)
class CategoryRepo {
public function all(): Collection {
return Cache::remember('categories:all', now()->addHours(6), function () {
return Category::query()->orderBy('rank')->get(['id','name','slug']);
});
}
}
- Key naming: think
resource:conditions:version
. - Locality: target lists/dictionaries. For user-specific data, keep TTL short or use another layer.
2.3 Short-lived query cache
$top = Cache::remember("posts:top:{$page}", 60, fn() =>
Post::with('author')
->published()
->orderByDesc('score')
->paginate(20)
);
- Short-lived cache for heavy lists with pagination +
with()
. - Invalidate via events (on create/update/delete call
Cache::forget
).
2.4 HTTP cache (ETag/Last-Modified)
$etag = sha1($post->updated_at.$post->id);
if (request()->header('If-None-Match') === $etag) {
return response()->noContent(304);
}
return response()->view('post.show', compact('post'))
->header('ETag', $etag)
->header('Cache-Control', 'public, max-age=120');
- Combine with a CDN to reduce bandwidth.
- For authenticated pages, prefer
private
and short lifetimes.
3. Eloquent optimization: N+1, indexes, aggregation
3.1 Detect and eliminate N+1s
// Example: fetch author and tags together
$posts = Post::with(['author:id,name','tags:id,name'])
->latest()->paginate(20);
- Use
with()
to eager load relations; select only needed columns. - For aggregate displays, consider counter caches or pre-aggregated tables.
3.2 Index design
- Add composite indexes to columns in WHERE/ORDER BY/JOIN.
- For
created_at DESC
ordering, stabilize with(created_at, id)
, etc. - For LIKE searches, prefer prefix matches (
column LIKE 'abc%'
); consider a separate store for full-text needs.
3.3 Whitelist search/sort options
$sort = $req->enum('sort', ['-created_at','created_at','-score','score']) ?? '-created_at';
$dir = str_starts_with($sort,'-') ? 'desc' : 'asc';
$col = ltrim($sort,'-');
$query->orderBy($col, $dir);
- Ban arbitrary
orderBy
; whitelist to keep to optimized paths.
3.4 Separate aggregation
- For heavy dashboard aggregates, materialize periodically and keep the view read-only/lightweight.
- If real-time is unnecessary, update asynchronously.
4. View optimization: Blade and front-end slimming
4.1 Conditionals & components
- Reduce
@include
within large loops; render in batches of collections. - Preformat heavy helpers via ViewModels or Accessors.
4.2 Images & static assets
- Set width/height on
<img>
to suppress CLS. - Use
<picture>
,srcset
, andloading="lazy"
to cut bytes. - With HTTP/2, bundle smartly; tree-shake unused CSS/JS at build time.
4.3 Accessible loading states
<div role="status" aria-live="polite" class="mb-2">
Loading data…
</div>
<ul aria-busy="true" aria-describedby="loading-desc">
<li class="skeleton h-6 w-full"></li>
<li class="skeleton h-6 w-5/6 mt-2"></li>
</ul>
<p id="loading-desc" class="sr-only">The list will appear once loading completes.</p>
- Skeleton UIs must include text descriptions.
- After completion, set
aria-busy="false"
and move focus to the list head to restore context.
5. Asynchrony: job splitting, de-dup, throttling
5.1 Offload heavy work to jobs
class ExportOrders implements ShouldQueue {
public $tries = 3;
public $timeout = 1800; // 30 min
public function handle(ExportService $svc) { $svc->run(); }
}
- Ensure idempotency (same input, same output) so retries don’t break state.
5.2 De-duplication (unique jobs)
$lock = Cache::lock("export:{$userId}", 600);
if ($lock->get()) {
ExportOrders::dispatch($userId)->onQueue('exports');
// Release $lock->release() when the job finishes
}
- Serialize same-type work per user to protect resources.
5.3 Rate-limit middleware
public function middleware(): array {
return [ new \Illuminate\Queue\Middleware\RateLimited('exports') ];
}
- Apply throttling for external APIs and email sends.
5.4 Front-end fallback
- If real-time push is unavailable, use polling.
- While loading use
role="status"
, and on failure provide brief cause + next steps.
6. Octane/OPcache/process tuning
6.1 OPcache basics
- In production:
opcache.enable=1
,opcache.validate_timestamps=0
(for immutable builds). - Restart processes on deploy to refresh caches.
6.2 Where Laravel Octane helps
- Cuts synchronous I/O overhead.
- Share session/cache/queues via external stores.
- Avoid stateful singletons and cross-request pollution; periodically re-init (e.g.,
Octane::tick()
).
6.3 Serve images/statics via CDN
- If the app doesn’t have to serve it, serve via CDN.
- Use signed URLs for time-limited access.
7. Observability: logs, traces, metrics
7.1 Structured logs
Log::info('order.created', [
'order_id' => $order->id,
'user_id' => $order->user_id,
'amount' => $order->amount,
'request_id' => request()->header('X-Request-Id')
]);
- Always log searchable keys (user ID, order ID, request ID).
- Mask PII, never log tokens/secrets.
7.2 Latency, error rate, throughput
- Track percentiles (p50/p95/p99) to reflect user experience.
- “Slow ≠ bad” — define SLOs and detect deviations.
- Compare metrics per deploy; roll back on regressions.
7.3 Telescope/Horizon
- Telescope: visibility into requests/queries/exceptions/jobs.
- Horizon: queue backlogs, failed jobs, processing time dashboards.
- Lock down in production; enable briefly for forensic purposes.
8. Pagination & incremental loading
8.1 simplePaginate
and infinite scroll
$items = Item::orderByDesc('id')->simplePaginate(50);
- Avoid the cost of total-count queries.
- For infinite scroll, also provide a manual control (“Load more” button).
8.2 Accessible loading
<button id="more" class="btn" aria-controls="list" aria-describedby="load-hint">
Load more
</button>
<p id="load-hint" class="sr-only">Additional results will be appended at the bottom.</p>
<div id="alist" role="feed" aria-busy="false"></div>
- Ensure it’s operable even without auto-loading.
9. Make failures harmless: timeouts, retries, circuit breakers
- For HTTP clients, set explicit connect/response timeouts.
- Retry transient errors with exponential backoff.
- For persistent failures, open a circuit temporarily and show fallback copy in the UI.
- Limit blast radius with scopes (transactions/partial updates).
10. i18n & time-zone performance
- Set Carbon locale once at boot.
- Provide translations via JSON or named keys to improve cache hit rates.
- Consolidate currency/date formatting on the server; minimize client-side computation.
11. Security vs. speed balance
- Apply stricter rate limits to more sensitive operations.
- Enforce CSP/HSTS/Referrer-Policy via headers, and if using dynamic nonces, minimize template overhead.
- For signed URLs and auth checks, be mindful of routes that must bypass caches.
12. UX: guidance for slow, failed, and offline states
12.1 Slow-response guidance
<div id="status" role="status" aria-live="polite">
No response for over 3 seconds. Please check your connection.
</div>
- As time elapses, show short guidance and next steps (reload, lite mode, etc.).
12.2 Failure guidance
- Put a “Try again” button front and center.
- Display a request ID usable for support.
- Don’t rely on color alone—use icon + text for states.
13. Implementation samples: from measuring bottlenecks to fixes
13.1 Measure response time via middleware
class RequestTimer {
public function handle($req, Closure $next) {
$start = microtime(true);
$res = $next($req);
$ms = (int)((microtime(true) - $start) * 1000);
Log::info('http.timing', [
'path' => $req->path(),
'status' => $res->getStatusCode(),
'ms' => $ms,
'request_id' => $req->headers->get('X-Request-Id'),
]);
return $res->headers->set('Server-Timing', "app;dur={$ms}");
}
}
- In the browser Network panel, inspect
Server-Timing
to spot slow views.
13.2 Visualize N+1s (dev only)
DB::listen(function ($query) {
if (str_contains($query->sql, 'select') && $query->time > 30) {
logger()->debug('slow.query', ['sql' => $query->sql, 'ms' => $query->time]);
}
});
- Log queries above a threshold.
13.3 Fundamental query improvements
- Use
withCount
andselectRaw
to push aggregation onto the DB. - Accumulate history/stats in separate tables to avoid write-heavy hot paths.
14. Checklist (for distribution)
Measurement
- [ ] p50/p95/p99, error rate, throughput
- [ ] slow query / slow request logs
- [ ] Dashboard comparing with the latest deploy
Reduction
- [ ] N+1 eliminated (
with()
/ column minimization) - [ ] Appropriate indexes
- [ ] Pruned recalculation / template branching
Caching
- [ ] Config/Route/View/OPcache
- [ ] Short-lived data cache + invalidation strategy
- [ ] HTTP ETag/Last-Modified/CDN
Asynchrony
- [ ] Jobify + retries + timeouts
- [ ] Unique jobs / rate limiting
- [ ] Fallbacks (polling / lite mode)
Platform
- [ ] Consider Octane coverage
- [ ] CDN for static assets
- [ ] Image
srcset
/ lazy-load / width & height
Observability
- [ ] Structured logs & request IDs
- [ ] Safe operation of Horizon/Telescope
- [ ] Alert thresholds & rollback procedures
Accessibility
- [ ] Loading copy +
role="status"
- [ ] State indications beyond color; focus restoration
- [ ] Next actions on failure
15. Common pitfalls and how to avoid them
- Caching while N+1s remain → Eliminate first, cache second.
- Long-lived caches without invalidation → Event-driven invalidation.
- Passing arbitrary sort/search straight through → Whitelist for optimized paths.
- Feeling safe after “just async” → Without retries/idempotency, failures pile up.
- Silent loading states → Provide brief guidance and ways to act.
- Optimizing without measurement → You won’t know impact; run measure → improve → re-measure.
16. Summary
- Advance in order: measure → reduce → cache → async → platform hardening → observe.
- For Eloquent, use
with()
+ indexes and externalize aggregates to lighten fundamentals. - Make jobs resilient with de-dup, rate limits, idempotency.
- Boost baseline speed with OPcache/Octane/CDN.
- Keep loading/failure/retry flows accessible.
- Put metrics on dashboards to spot and roll back regressions early.
Performance isn’t a one-off fix—it’s a habit of measuring and improving. Use this template to grow a team-wide Laravel that’s fast and understandable.
References
- Laravel (official)
- Performance/HTTP
- Databases
- Accessibility/UX