Practical Laravel performance optimization techniques from query tuning and caching to queue offloading and OpCache configuration.
The cardinal sin of performance optimization is guessing where the bottleneck is. Install proper profiling tools before changing a single line of code.
Essential profiling tools for Laravel:
Profile your slowest endpoints. Identify whether the bottleneck is database, PHP processing, external API calls, or disk I/O. Then optimize the actual bottleneck, not what you assume is slow.
These require zero code changes and should be applied to every production Laravel deployment.
php artisan config:cache
php artisan route:cache
php artisan event:cache
php artisan view:cache
config:cache collapses all config files into a single cached file, eliminating dozens of file reads per request. route:cache pre-compiles route registration into a PHP array that loads in milliseconds instead of parsing route files.
Important: After caching config, calls to env() outside of config files return null. Always reference environment variables through config values, never directly via env().
OPcache stores compiled PHP bytecode in shared memory, eliminating the need to parse PHP files on every request. The performance impact is dramatic: 2-3x faster response times in most applications.
opcache.enable=1
opcache.memory_consumption=256
opcache.max_accelerated_files=20000
opcache.validate_timestamps=0
opcache.revalidate_freq=0
Setting validate_timestamps=0 means OPcache never checks if files have changed. After each deployment, run php artisan opcache:clear or restart PHP-FPM to load the new code.
Match your PHP-FPM pool configuration to your server resources:
pm = dynamic
pm.max_children = 50
pm.start_servers = 10
pm.min_spare_servers = 5
pm.max_spare_servers = 20
pm.max_requests = 500
max_children should be calculated based on available memory divided by average memory per process. If each PHP process uses 50 MB and you have 4 GB available for PHP, set max_children to around 80. Exceeding available memory causes swapping, which destroys performance.
Enable strict mode in development:
// AppServiceProvider::boot()
Model::preventLazyLoading(! $this->app->isProduction());
This throws an exception whenever a relationship is lazy-loaded, forcing developers to use eager loading. In production, log violations instead of throwing exceptions.
Never load an entire table into memory:
// Bad: loads all 500,000 users into memory
User::all()->each(fn ($user) => $this->process($user));
// Good: processes in chunks of 1,000
User::chunk(1000, fn ($users) => $users->each(
fn ($user) => $this->process($user)
));
// Better for simple iterations: lazy collections
User::lazy()->each(fn ($user) => $this->process($user));
Use select() to limit columns:
// Fetching 20 columns when you need 3 wastes memory and I/O
User::select('id', 'name', 'email')->where('active', true)->get();
Use raw expressions for complex aggregations:
Order::selectRaw('DATE(created_at) as date, SUM(total) as revenue')
->groupByRaw('DATE(created_at)')
->get();
Use exists() instead of count() when checking for presence:
// Slow: counts all matching rows
if (Order::where('user_id', $userId)->count() > 0) { ... }
// Fast: stops at the first match
if (Order::where('user_id', $userId)->exists()) { ... }
For pages that are identical for all users (marketing pages, documentation, public product listings), cache the entire response:
Route::get('/pricing', PricingController::class)
->middleware('cache.headers:public;max_age=3600');
Or use a reverse proxy (Varnish, Nginx FastCGI cache) in front of your application for even better performance.
Cache expensive view components independently:
// In your Blade view
@cache('user-dashboard-stats-' . auth()->id(), 300)
<x-dashboard-stats :stats="$this->calculateStats()" />
@endcache
Cache database queries that are expensive and do not change frequently:
$categories = Cache::remember('product-categories', 3600, function () {
return Category::with('subcategories')->orderBy('name')->get();
});
Any work that does not need to complete during the HTTP request belongs in a queue:
// Instead of this (blocks the response for 2-3 seconds):
Mail::to($user)->send(new InvoiceGenerated($invoice));
// Do this (returns immediately):
Mail::to($user)->queue(new InvoiceGenerated($invoice));
The user gets an instant response. The email sends in the background. If the mail server is temporarily unavailable, the queue retries automatically.
Use Vite for frontend builds. Vite's build output is optimized by default: minified, tree-shaken, code-split, and gzip-ready.
Optimize images. Use modern formats (WebP, AVIF) and serve responsive sizes. A 4 MB hero image on a mobile device is inexcusable.
Lazy load below-the-fold content. Images, iframes, and heavy JavaScript components that are not visible on initial page load should use lazy loading.
Performance optimization is not a one-time task. Monitor continuously:
Set alerts for degradation. A 10% increase in p95 response time this week compared to last week should trigger investigation, not wait until users complain.
Whether you're modernizing your infrastructure, navigating compliance, or building new software - we can help.
Book a 30-min Call