So I’m staring at the server graphs on a sleepy Tuesday, coffee in hand, wondering why a perfectly fine Laravel app suddenly feels like it’s running through molasses. The code was clean, the database wasn’t screaming, and yet requests were dragging their feet. Ever had that moment when you’re sure the problem isn’t the code… but something beneath it? That was me. And that little rabbit hole led to a routine I now run on every Laravel production server: tune PHP‑FPM pools so they don’t choke, set OPcache like a grown‑up, switch queues to Horizon with sensible limits, lean hard on Redis without letting it hoard memory, and—when the app needs it—spin up Octane for a real boost.
Here’s the thing: Laravel is fast, but the stack around it decides whether your users feel that speed. Your process manager can starve, your OPcache can waste memory, Redis can keep zombie keys forever, and your worker setup can quietly snowball until it flattens the box. None of this is flashy. All of it is fixable.
In this guide, I’ll walk you through the production tune‑up I rely on: practical PHP‑FPM pool sizing, OPcache settings that survive deploys, a Horizon layout that won’t collapse under a heavy queue, Redis tweaks that keep memory honest, and where Octane really makes sense. I’ll share the little checks that save me during incident calls, and the playbook I wish I had years ago. Grab a coffee; let’s make your Laravel production fly.
İçindekiler
- 1 The PHP‑FPM Pool Mindset: Fewer Surprises, More Throughput
- 2 OPcache: Your Code, But Pre‑Warmed and Ready
- 3 Queues and Horizon: Calm Workflows, Happier Users
- 4 Redis Tuning That Won’t Bite You Later
- 5 When Octane Is Worth It (And When It’s Not)
- 6 Safe Deploys, Real Monitoring, and the Little Habits That Keep You Fast
- 7 Real‑World Scenarios and the Fixes I Reach For
- 8 A Quick Word on CDN, Caching Layers, and When to Offload
- 9 Putting It All Together
The PHP‑FPM Pool Mindset: Fewer Surprises, More Throughput
I used to think of PHP‑FPM as a mysterious black box. Then I watched a single noisy API endpoint eat every worker and force the rest of the app to queue behind it. That’s when it clicked: your pool is your lane discipline. If everyone piles into one lane, traffic stops. Split lanes wisely, and you cruise.
Start with one pool, earn your second
If you’re running a single Laravel app, you can absolutely start with one pool. Keep it simple: a dedicated Unix socket for Nginx, a reasonable number of workers, and clear limits. If you have separate concerns—like a public site and a heavy admin, or a /api that runs expensive requests—consider a second pool with its own worker cap and timeout. Think of it like giving your most demanding route its own lane so it can’t block the rest.
How I size workers without guesswork
Here’s the mental model I use. Each PHP‑FPM child is a fully loaded PHP process. Give each child a budget—say 60–120 MB depending on extensions, OPcache, and workload. Then look at your RAM, keep cushion for the OS, Nginx, Redis, MySQL, and background workers. If you can safely afford ten workers at 100 MB each, set pm.max_children to ten and resist the urge to max it out. More workers aren’t always better; they’re just more mouths to feed.
; /etc/php/8.2/fpm/pool.d/laravel.conf
[laravel]
user = www-data
group = www-data
listen = /run/php-fpm-laravel.sock
listen.owner = www-data
listen.group = www-data
pm = dynamic
pm.max_children = 10
pm.start_servers = 3
pm.min_spare_servers = 2
pm.max_spare_servers = 5
request_terminate_timeout = 60s
request_slowlog_timeout = 5s
slowlog = /var/log/php8.2-fpm/slow.log
; Turn on status page for quick checks
pm.status_path = /fpm-status
Dynamic works great for most apps. On very traffic‑heavy servers where workload is predictable, pm = static can add stability because you know exactly how many children run. If you have low traffic most of the day but sudden bursts, pm = ondemand avoids idle processes. Don’t obsess; pick one that matches your traffic pattern and check real metrics a day later.
Timeouts and slow logs are your smoke alarms
request_terminate_timeout stops runaway requests. I prefer 60 seconds to catch upstream issues without nuking legitimate long tasks (which should be in queues anyway). request_slowlog_timeout is my favorite: five or eight seconds writes a stack trace of slow PHP requests. It has saved me from too many “random slowness” mysteries to count. Enable it and check the slow log after deploys. You’ll thank yourself.
Nginx and pool routing without drama
Keep Nginx upstreams short and direct. If you split pools (say, /api and web), route paths to different sockets and give the API a lower pm.max_children if it tends to be heavy. That way, a bad actor or heavy report can’t starve your homepage.
Watch, adjust, don’t guess
When I bring a new Laravel app online, I deploy, baseline traffic, then spend an afternoon watching workers in htop and the PHP‑FPM status page. Are children constantly maxed? Drop the concurrency of your queue workers or bump max_children slightly. Is RAM tight? Trim workers and cache more. If you want a simple, durable monitoring stack that never lets me down, I’ve shared my playbook in the Prometheus + Grafana setup I use to keep a VPS calm. It’s friendly, and it works.
OPcache: Your Code, But Pre‑Warmed and Ready
Laravel without OPcache is like driving a sports car in first gear. You’ll move, sure, but why would you? OPcache compiles your PHP files once and serves bytecode from memory. The trick is giving it just enough room and setting it up to behave during deploys.
The settings I reach for first
; /etc/php/8.2/fpm/conf.d/10-opcache.ini
opcache.enable=1
opcache.enable_cli=0
opcache.memory_consumption=256
opcache.interned_strings_buffer=16
opcache.max_accelerated_files=20000
opcache.validate_timestamps=0
opcache.save_comments=1
opcache.fast_shutdown=1
If you have frequent deploys, opcache.validate_timestamps=0 is your friend—just remember to reload FPM during deploys so it clears and reloads memory. On systems where you can’t reload, you can leave timestamps on and set a small opcache.revalidate_freq, but you’ll pay a tiny check overhead. I prefer clean reloads.
If you’re curious about the why behind each directive, the official configuration notes are a pleasant rabbit hole when you have time: the official OPcache configuration reference is clear and practical.
Deploys that don’t trip over OPcache
Atomic, symlinked deploys are the way to go: build to a new release directory, update the symlink, warm caches, reload services. Right after the symlink flips, I like to run any cached warmups and then systemctl reload php-fpm (or the versioned service) so OPcache starts fresh with the new paths. This pairs nicely with Laravel’s own cache warmers:
php artisan config:cache
php artisan route:cache
php artisan view:cache
When someone tells me their site got slower after a deploy, my first nudge is “clear the code path and warm the caches.” Nine times out of ten, OPcache was full, or configs weren’t cached.
Should you enable JIT?
I get this question a lot. In my experience, JIT rarely moves the needle for typical Laravel web requests. If you’re doing numeric heavy lifting or specific algorithms in PHP, it might help. For most apps, keep JIT off and invest that energy in better database queries, caching, and queues.
Queues and Horizon: Calm Workflows, Happier Users
One of my clients once ran all emails, webhooks, and report generation on a single queue worker. It was fine until a partner dumped a thousand webhook retries at 10:03 AM on a Monday. Everything clogged. The site looked “slow” because pages waited on jobs that should’ve been background‑only. The fix that changed their week: Horizon with focused queues and sane limits.
Separate what you can, constrain what you can’t
Split your jobs by behavior. I like a default queue for common quick tasks, a mail queue for emails, a webhooks queue that uses tighter rate limits, and a heavy queue for reports and imports. This gives you knobs: if imports go wild, you can cap the heavy queue at two workers while the default and mail queues keep the site snappy.
A Horizon layout I reach for often
// config/horizon.php
'defaults' => [
'tries' => 3,
'timeout' => 60,
'maxTime' => 300,
'balance' => 'simple',
],
'environments' => [
'production' => [
'supervisor-default' => [
'connection' => 'redis',
'queue' => ['default'],
'minProcesses' => 3,
'maxProcesses' => 6,
'balanceMaxShift' => 1,
'balanceCooldown' => 3,
],
'supervisor-mail' => [
'connection' => 'redis',
'queue' => ['mail'],
'minProcesses' => 1,
'maxProcesses' => 2,
],
'supervisor-webhooks' => [
'connection' => 'redis',
'queue' => ['webhooks'],
'minProcesses' => 1,
'maxProcesses' => 2,
'timeout' => 30,
],
'supervisor-heavy' => [
'connection' => 'redis',
'queue' => ['heavy'],
'minProcesses' => 1,
'maxProcesses' => 2,
'timeout' => 300,
],
],
],
Two ideas are doing the heavy lifting here. First, each type of work has its own sandbox. Second, the max worker counts stop “helpful” teammates from turning a knob to twelve and starving PHP‑FPM. Put a memory limit on your workers, too. And remember to match timeout to reality; if your webhook provider expects a response in ten seconds, don’t let that worker sit for a minute.
Backoff, retries, and idempotency
Make retries smarter with exponential backoff. A broken third‑party shouldn’t spiral your queue. And aim for idempotent jobs—if a webhook fires twice or a job retries, it should be safe. This is less about performance and more about stability, but it’s the difference between recovery and chaos.
Horizon vs. queue:work
I still use queue:work for tiny projects. But as soon as you’re juggling multiple queues or tuning concurrency, Horizon pays for itself. The dashboard is honest about what’s happening, and the process management is worth its weight in uptime. If you want a reference while you configure it, I like to skim the official docs when people ask me about features: the Horizon documentation is straightforward and helpful.
Redis Tuning That Won’t Bite You Later
Redis is the heartbeat of a busy Laravel app: cache, queues, rate limiters, locks. It’s blazingly fast until it isn’t, and when it runs out of memory or blocks on disk, you feel it instantly. Here’s how I tune it so it stays friendly under load.
Give it a memory budget and an eviction story
I always set maxmemory and pick an eviction policy on non‑critical caches. For Laravel cache and queues, allkeys-lru is a good default: it evicts the least recently used keys when memory fills. If you rely heavily on TTLs, volatile-lru can work. What matters is that you choose a policy on purpose and never let Redis just keel over. If you’re curious about how these policies behave, the official note on Redis eviction policies is a great explainer.
# /etc/redis/redis.conf
maxmemory 1gb
maxmemory-policy allkeys-lru
# Safer AOF persistence for most apps
appendonly yes
appendfsync everysec
# Keep client buffers bounded (watch pubsub in particular)
client-output-buffer-limit normal 0 0 0
client-output-buffer-limit pubsub 32mb 8mb 60
If you push a lot of logs or events through Laravel broadcasting, keep an eye on the pubsub buffer. It can grow quietly and then smack you during peaks. The limits above are a friendly guardrail.
Don’t let queues and cache fight
Use separate Redis databases or, better, separate instances for cache vs queues when possible. Cache churn can evict queue metadata under aggressive policies. If you’re on a single instance, at least split DB indexes and keep an eye on memory. On busy systems, I like to run a dedicated Redis for Horizon/queues and another for cache. It keeps the traffic patterns clean.
TTL discipline and key hygiene
Always, always set TTLs for cache keys that don’t need to live forever. Laravel’s cache helpers make this a one‑liner. If you’re caching heavy computed results, be intentional about how long they live and refresh them in the background. Stale keys are easy to ignore until they block legitimate data from staying warm.
Latency and disk safety
On virtualized servers or noisy hosts, Redis can spike latency if disk I/O gets weird. AOF with everysec is a good tradeoff for many apps. If you care more about speed than persistence for cache data, you can keep AOF on for queues and critical locks while using a separate instance for cache without persistence. And keep huge pages disabled unless you’ve profiled—Redis can be finicky about memory settings under certain kernels.
If you want a friendly dive into why Redis often beats Memcached for app workloads—and when it doesn’t—I shared thoughts in my Redis vs Memcached guide with TTL and eviction tuning. Even though it’s written for WordPress, the ideas map cleanly to Laravel.
When Octane Is Worth It (And When It’s Not)
Octane is the “keep the framework in memory” button. Instead of booting Laravel on every request, it stays hot using Swoole or RoadRunner. The first time I flipped it on for a read‑heavy API, latency dropped so hard the team thought I’d broken the logs. But I’ve also seen folks switch it on without changing anything else, then wonder why their app got weirder.
What you gain, what you must change
The gain is obvious: no repeated bootstrap per request. The change is subtle: your app is long‑running now. That means you have to be careful with state. Static properties, singletons holding per‑request data, “just‑for‑this‑request” caches—these can bleed between users if you’re not thoughtful. It’s not scary, but it demands discipline.
A gentle starting point
# install one engine, e.g. Swoole
composer require laravel/octane
php artisan octane:install
# config/octane.php highlights
'server' => env('OCTANE_SERVER', 'swoole'),
'workers' => env('OCTANE_WORKERS', 4),
'tasks' => env('OCTANE_TASK_WORKERS', 2),
'watch' => false,
Start simple: one worker per CPU core is a safe baseline, and a small number of task workers help with out‑of‑band jobs. Don’t crank it to the moon on day one. Drop Octane behind Nginx, keep PHP‑FPM for non‑Octane paths if you need them, and watch your logs closely for “leaks”—things that should reset each request but don’t.
Laravel’s docs are a nice companion for this part. If you want to skim what’s new or double‑check a detail, keep the Octane documentation handy while you experiment.
Database connections, caches, and events
Close and re‑open DB connections between requests if you see stale issues, or rely on Laravel’s built‑in connection management routines. Avoid caching per‑request data in singletons, and prefer request‑scoped containers for anything that depends on the authenticated user. Logs are your friend here—if a user sees someone else’s data even once, roll back and review your state surfaces.
Octane’s sweet spot
Octane shines for read‑heavy APIs and sites that do a lot of framework bootstrapping per request. If your bottleneck is the database, fix that first. If the problem is cold boot, Octane is the right lever. I treat it like a second‑phase optimization after I’ve trimmed PHP‑FPM, tuned OPcache, and cleaned up queries.
Safe Deploys, Real Monitoring, and the Little Habits That Keep You Fast
I used to think performance was about big switches. Flip Octane, double workers, crank memory. These days, I win more with small habits: deploys that don’t shock the system, caches that stay warm, queues that drain before I reload anything, and dashboards that actually tell me when I’ve gone too far.
Warmth before traffic
On deploy, I like to build in a temp directory, run composer with opcache and autoload optimization flags, cache configs/routes/views, run migrations with a maintenance window if needed, then flip the symlink. Right after, I ping a few hot endpoints to warm OPcache and any internal caches. Finally, I reload PHP‑FPM so OPcache is fresh. It keeps that “first request after deploy” dip away.
Drain Horizon, don’t drop it
If you’re moving to a new release that changes job structures, pause Horizon and let workers finish. Then deploy. Then resume. It takes a minute and it saves you from jobs failing because a class moved or a serializer changed. Little rhythm, big payoff.
Track the signals that matter
For Laravel servers, my priority list is simple: PHP‑FPM busy vs idle workers, request duration quantiles, Redis memory and latency, queue wait times, Horizon runtime, and database slow queries. If you want a broader plan for the server itself, this walkthrough is as close to a plug‑and‑play as it gets: the Prometheus + Grafana monitoring guide I rely on.
Fast storage and honest capacity
When I migrate apps that always feel “sticky,” storage is often the hidden culprit. If your queues, logs, and cache files are hitting slow disks, everything drags. If you’re choosing a new box and want practical, real‑world guidance on storage and CPU, I shared my philosophy in this NVMe VPS hosting deep dive and a more Laravel‑specific take in the guide I use to choose VPS specs for Laravel. Skip the guesswork; it pays off.
Database: the quiet backbone
Even the best PHP tuning can’t hide a missing index or an N+1 query spree. Keep an eye on slow query logs, add the indexes your app keeps asking for, and lean on caching for expensive reads. If you like checklists, the ideas in my InnoDB tuning checklist translate well to Laravel’s database patterns. Clean queries make everything else easier.
Real‑World Scenarios and the Fixes I Reach For
Let me stitch this together with a few snapshots I’ve bumped into this year.
“CPU is fine but response times spike randomly”
I check the PHP‑FPM slow log first. If I see a cluster of traces around a route, I profile that controller and its queries. Nine times out of ten, it’s a slow external API call or an eager load that should’ve been a cache. I add timeouts to the external call, move long work to a queue, and cache the heavy read. Response times smooth out immediately.
“Queues are always behind, even off‑peak”
I’ve seen this when Horizon’s worker counts are generous but Redis is fighting eviction because cache keys never expire. Jobs end up waiting while the instance thrashes memory. The fix is two‑part: give Redis a budget and TTL discipline, then constrain the heavy queue so it can’t hog everything. Suddenly, “behind” becomes “caught up.”
“Octane made our app fast… and weird”
That “weird” is state leak. View a page as user A, then as user B, and you see traces of A—old data in a singleton, a lingering service that cached the wrong thing. I audit singletons, move per‑request bits to scoped containers, and add a guard that resets state on each request. The speed stays, the weird goes away.
“After deploy, the first few requests are slow”
Classic cold cache. Warm OPcache with a few synthetic hits, cache routes/config/views, and reload PHP‑FPM to clear stale code maps. If you’re shipping containers, build with the caches pre‑baked and run a startup probe that hits hot paths before sending traffic.
“Memory leak or just busy?”
People call everything a memory leak until you show them a graph. If PHP‑FPM memory climbs during a burst and drops after, that’s normal. If it climbs and never falls, check for genuine leaks in extensions or a worker count that’s too high. Drop workers, watch for stability, then add back one at a time. Incremental beats heroic.
A Quick Word on CDN, Caching Layers, and When to Offload
Laravel can do a lot, but it doesn’t need to do everything. If you’re serving heavy static assets or cacheable pages for logged‑out users, let your CDN and Nginx help. Edge caching plus sensible Cache‑Control headers can make your app feel instant. If this is unexplored territory for you, I collected a friendly, practical playbook in this CDN caching guide. It’ll give you ideas to steal for Laravel, especially around headers and bypass rules.
Putting It All Together
Performance tuning isn’t a one‑time hero moment; it’s a set of habits. Size your PHP‑FPM pools with room to breathe and logs that tell you when requests drag. Give OPcache the memory it deserves and pair it with deploys that warm the path. Move heavy work to queues, then give Horizon a layout that keeps each type of job in its own lane. Let Redis be fast without letting it hoard memory—set maxmemory, choose an eviction policy on purpose, and separate queues from caches if you can. And if you’re ready for a bigger jump, bring Octane online with respect for state, not fear.
Here’s my parting advice: change one thing at a time and watch it. A single morning with good dashboards will beat a month of guessing. Baseline before you touch anything, tune, then check again. Keep a notepad of what helped and what didn’t. Over time, your instincts sharpen, and suddenly you’re that person on the team who can look at a graph and say, “I know exactly what to try.” Hope this was helpful! If you want me to dig into a specific setup in a future post, tell me what you’re running and what’s bugging you—I’m all ears.
