A Laravel application can slow down even when CPU use looks modest. Requests may be waiting for PHP workers, database locks, external services or storage. Start by identifying the slow operation and correlating its traces with resource and queue metrics. Changing worker counts or enabling another runtime without this baseline can move the bottleneck or introduce correctness problems.
This guide covers Laravel production optimization across PHP-FPM, OPcache, Horizon, Redis and Octane. The examples target Laravel 12, Horizon 5.x and Octane 2.x. PHP 8.2 is supported by Laravel 12; confirm the requirements of your locked dependencies and installed extensions. The Debian-style PHP paths below are examples, not universal service locations.
The aim is to size concurrency from measurements, refresh code reliably during deployments, keep queue data out of disposable-cache eviction policies, and evaluate Octane where application bootstrapping is material. Octane is an alternative long-lived HTTP runtime, not an extra acceleration layer inside FPM workers. Queue processing and Redis capacity still need their own limits.
PHP-FPM Pools: Memory, Concurrency and Observability
A PHP-FPM pool provides a bounded group of workers. A slow endpoint can occupy them and make unrelated requests wait. Separate pools can reserve request-processing slots, but all pools may still share CPU, memory and database connections. Pool separation is useful workload organization, not complete resource isolation.
Start with one pool, earn your second
For one Laravel application, begin with a dedicated socket and an explicit worker budget. If an admin or API workload needs separate capacity, route it to another pool with its own cap and timeout. Add both pools to the host budget; where stronger isolation is needed, use appropriate operating-system resource limits or separate hosts as well.
Size workers from representative peak demand
Measure representative peak private memory and the process group’s proportional memory (PSS), including expensive routes and bursts. A short average can miss large requests; multiplying RSS by worker count can double-count shared OPcache pages. Reserve memory for the OS, database, Redis, OPcache and background workers, then budget the FPM pool with headroom. CPU and downstream connection limits can require lower concurrency than the memory calculation permits. The ten-worker example below is illustrative; on the stated Debian-style installation the pool file is /etc/php/8.2/fpm/pool.d/laravel.conf. Before using it, ensure the www-data identity exists, Nginx can access its socket, and the dedicated slowlog directory exists with permissions allowing the FPM master to open the log. Use restricted log permissions and rotation; do not make the directory world-writable.
; PHP 8.2 on a Debian-style installation; adapt paths/users.
[laravel]
user = www-data
group = www-data
listen = /run/php-fpm-laravel.sock
listen.owner = www-data
listen.group = www-data
listen.mode = 0660
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
pm.status_path = /fpm-statusdynamic adjusts the number of children using the start/min/max spare settings. Those spare settings do not apply to ondemand, which removes idle workers but can add process-start latency during bursts. static keeps a fixed worker count. Compare queue length, memory pressure, error rate and p95/p99 request duration before and after changing modes; none is universally best.
Timeouts and slow logs are your smoke alarms
request_terminate_timeout kills the worker when a request exceeds the limit; it does not move work into a queue or undo partial side effects. Align application connection/read timeouts with the FPM and proxy limits, and explicitly dispatch suitable long work from application code. request_slowlog_timeout records a PHP backtrace after its threshold; use it with application tracing to investigate slow queries or external calls. The example’s 60-second termination and 5-second slowlog thresholds need to match the actual service objectives.
Protect status routing and separate pool traffic
FPM status can expose request paths and capacity information. Put the exact deny location in every public application server block, then expose status through a separate loopback listener used only by trusted local monitoring. This avoids treating every request forwarded by a local reverse proxy as trusted. Ensure port 8081 is free, use your installation’s fastcgi_params include and socket, and never expose this listener through a public proxy or port mapping. Local users able to reach it can read status.
# Inside EACH public application server block:
location = /fpm-status { return 404; }
# Separate server block in the http context; do not proxy to it publicly.
server {
listen 127.0.0.1:8081;
server_name localhost;
location = /fpm-status {
include fastcgi_params;
fastcgi_param SCRIPT_FILENAME /fpm-status;
fastcgi_pass unix:/run/php-fpm-laravel.sock;
}
location / { return 404; }
}Test Nginx syntax and verify both outcomes before enabling monitoring: the public route returns 404, while the loopback endpoint returns FPM status. If API traffic uses a second FPM pool, its routing and socket need their own configuration; reserve worker slots without assuming CPU or database isolation.
Watch, adjust, don’t guess
Watch active/idle processes, listen queue and the max-children-reached counter alongside request traces, memory and CPU. Saturation does not automatically justify more workers: an external dependency or database limit may be holding them. The protected status endpoint shares the pool in this example and may itself wait when it is saturated. The Prometheus and Grafana VPS monitoring guide provides broader monitoring context; set alerts from your application’s objectives.
OPcache Capacity and Code Refresh During Deployments
OPcache stores compiled PHP bytecode to avoid repeatedly compiling the same files. Inspect its script count, memory use and restart counters through access-controlled diagnostics. Set capacity from the application and host budget; the 256 MB and 20,000-file values below are starting examples, not measured requirements for every Laravel application.
A version-scoped FPM configuration example
; /etc/php/8.2/fpm/conf.d/10-opcache.ini
; Requires the OPcache extension to be loaded by this FPM SAPI.
opcache.enable=1
opcache.enable_cli=0
opcache.memory_consumption=256
opcache.interned_strings_buffer=16
opcache.max_accelerated_files=20000
; Use 0 only with a verified deployment cache-refresh procedure.
opcache.validate_timestamps=0
opcache.save_comments=1Use opcache.validate_timestamps=0 only with a reliable procedure that refreshes the relevant runtime on every deployment. Otherwise leave timestamp validation enabled and choose a suitable revalidation interval. A CLI opcache_reset() does not clear the separate FPM cache. Verify the installed FPM service’s reload/restart behaviour; refresh Octane and queue workers separately. opcache.enable_cli=0 in this FPM-oriented example is not a recommendation for tuning long-lived CLI runtimes.
opcache.fast_shutdown was removed in PHP 7.2.0, so it is omitted from this PHP 8.2 example; no replacement directive is needed. The official OPcache configuration reference documents each option. If file_cache is enabled separately, account for its disk cache and release paths in the deployment procedure too.
Deploys that don’t trip over OPcache
Build an immutable release directory and generate Laravel caches using the target production environment before it receives traffic. Switch the release and refresh the relevant FPM generation using a tested procedure, then send health and warm-up requests to the new workers. Warming first and resetting OPcache afterwards discards that work. A symlink switch alone does not guarantee zero downtime or update already-running processes. Keep the previous release available until old requests finish, and include database compatibility in rollback planning. These commands prepare Laravel’s application caches in the new release:
php artisan config:cache
php artisan route:cache
php artisan view:cache
After config caching, Laravel does not normally load .env during requests or Artisan commands; use env() inside configuration files and config() elsewhere. Diagnose a post-deployment slowdown with traces, cache statistics, migration timing and dependency checks. Cold or full caches are possibilities, not a universal explanation.
Should you enable JIT?
JIT accelerates suitable PHP computation; it cannot remove time spent waiting on a database or remote API. Keep the deployment simple unless comparable workload measurements show a useful gain. A numerical PHP workload may benefit differently from an I/O-heavy web request. Measure the actual application and PHP version instead of assuming JIT is either always useful or always ineffective.
Horizon Queues, Timeouts and Retry Behaviour
Consider a hypothetical application that mixes mail, webhook delivery and report generation. Long reports can delay shorter jobs on a shared worker. Separate queues and concurrency limits let you reserve capacity for each class. Moving a report to a queue shortens the web request only when the application returns a job status and lets processing continue asynchronously; it does not reduce the report’s computation by itself.
Separate what you can, constrain what you can’t
Use default for ordinary work, mail for messages, webhooks for outbound delivery and heavy for reports/imports when these are meaningful distinctions. A queue name alone does not apply rate limiting or priority. Configure provider-aware rate limits in the jobs/middleware and give each supervisor a resource budget. Horizon requires Redis-backed queues; protect its production dashboard with the viewHorizon authorization gate.
Horizon 5.x supervisor defaults and connection settings
// Relevant entries INSIDE the returned config/horizon.php array.
// Preserve other published entries, including middleware and Redis use.
'defaults' => [
'supervisor-default' => [
'connection' => 'redis', 'queue' => ['default'],
'balance' => 'auto', 'autoScalingStrategy' => 'time',
'tries' => 3, 'backoff' => [5, 15, 60],
'timeout' => 60, 'memory' => 128, 'maxTime' => 0,
],
'supervisor-mail' => [
'connection' => 'redis', 'queue' => ['mail'],
'balance' => 'auto', 'autoScalingStrategy' => 'time',
'tries' => 3, 'backoff' => [5, 15, 60],
'timeout' => 60, 'memory' => 128, 'maxTime' => 0,
],
'supervisor-webhooks' => [
'connection' => 'redis', 'queue' => ['webhooks'],
'balance' => 'auto', 'autoScalingStrategy' => 'time',
'tries' => 3, 'backoff' => [5, 15, 60],
'timeout' => 30, 'memory' => 128, 'maxTime' => 0,
],
'supervisor-heavy' => [
'connection' => 'redis', 'queue' => ['heavy'],
'balance' => 'auto', 'autoScalingStrategy' => 'time',
'tries' => 3, 'backoff' => [5, 15, 60],
'timeout' => 300, 'memory' => 256, 'maxTime' => 0,
],
],
'environments' => [
'production' => [
'supervisor-default' => [
'minProcesses' => 3, 'maxProcesses' => 6,
'balanceMaxShift' => 1, 'balanceCooldown' => 3,
],
'supervisor-mail' => [
'minProcesses' => 1, 'maxProcesses' => 2,
'balanceMaxShift' => 1, 'balanceCooldown' => 3,
],
'supervisor-webhooks' => [
'minProcesses' => 1, 'maxProcesses' => 2,
'balanceMaxShift' => 1, 'balanceCooldown' => 3,
],
'supervisor-heavy' => [
'minProcesses' => 1, 'maxProcesses' => 2,
'balanceMaxShift' => 1, 'balanceCooldown' => 3,
],
],
],Merge these entries into the published configuration; preserve the remaining settings and define any other environments explicitly. Each defaults key is a supervisor name. The matching production entry overrides its counts. All four use auto balancing; minProcesses, maxProcesses, balanceMaxShift and balanceCooldown are consistent with that choice. The example totals six minimum and twelve maximum job workers. These workers still share host resources with HTTP processes, Redis and the database.
// Relevant entry inside config/queue.php -> connections.
'redis' => [
'driver' => 'redis',
'connection' => 'queue', // config/database.php -> redis -> queue
'queue' => 'default',
'retry_after' => 360, // Greater than every Horizon timeout above.
'block_for' => 5,
'after_commit' => true,
],The Horizon connection named redis refers to config/queue.php. Its inner connection named queue must exist under config/database.php → redis and point to the dedicated, authenticated queue Redis instance; choose its real host/socket and credentials through the application environment. Configure Horizon’s own Redis use connection for that instance too, keeping the reserved horizon connection name out of custom definitions. The application cache connection must point to a separate disposable-cache instance. With after_commit=true, jobs dispatched within a database transaction wait for its commit; a rollback discards those pending dispatches. These fragments do not provision either service or credentials. Do not paste them into a setup whose named connections point elsewhere.
Keep job-level $timeout values below their Horizon supervisor timeout, and keep every supervisor timeout below retry_after with margin. Here the longest Horizon timeout is 300 seconds and retry_after is 360; shorter job-level limits and blocking client connect/read timeouts must fit inside those boundaries. PHP PCNTL is required for job timeouts. The worker memory option is a recycling threshold, not a hard bound on peak host memory. maxTime=0 leaves worker lifetime unlimited; it is not a job timeout. With simple balancing, fixed processes are distributed evenly; balance=false processes queues in listed order. Do not treat these modes as arbitrary queue weights.
Backoff, retries, and idempotency
tries=3 means up to three attempts, not three retries after the first attempt. The backoff array controls delay after unhandled exceptions; with three attempts only the first two retry delays will normally be used. Job-level options and middleware can affect attempts and delays. Timeouts, crashes and interrupted acknowledgements can cause duplicate work even with correct timeout ordering. Protect side effects with idempotency keys or transactional uniqueness and inspect failed jobs before retrying them. Rate-limit outgoing calls according to the provider’s contract.
Horizon vs. queue:work
Ordinary queue:work remains suitable when its capabilities and monitoring meet the application’s needs. Horizon adds Redis queue monitoring and supervisor orchestration; it does not guarantee delivery correctness or uptime. An operating-system process monitor must restart Horizon if it exits. Its supervisor process is distinct from the supervisor definitions in config/horizon.php. The Horizon documentation covers installation, authorization and deployment; use the Laravel 12 version for these examples.
Redis Memory, Eviction and Queue Durability
Redis can support cache, sessions, queues, rate limiters and locks, but these data classes have different loss and correctness requirements. Decide which data may be discarded and which must be retained before selecting eviction or persistence settings. Fast in-memory operations do not eliminate disk, replication, network or memory-pressure failure modes.
Give it a memory budget and an eviction story
allkeys-lru may suit disposable cache: it can evict any key when the memory limit is reached. It must not be applied to queue or lock data that the application needs to remain present. A dedicated queue instance using noeviction avoids key eviction, but memory-growing writes can fail when full, including job dispatch. Monitor errors and provision headroom. volatile-lru is not a general substitute: it only chooses keys with expiry and cannot solve every full-memory condition. Redis eviction policies explains these distinctions.
# QUEUE instance only: merge into its existing secured configuration.
# 1gb is an example budget, not a host-sizing recommendation.
maxmemory 1gb
maxmemory-policy noeviction
appendonly yes
appendfsync everysec
# Normal clients remain unlimited here; monitor their memory.
client-output-buffer-limit normal 0 0 0
# Pub/Sub clients: hard limit; soft limit sustained for 60 seconds.
client-output-buffer-limit pubsub 32mb 8mb 60
# On a DIFFERENT instance used only for disposable cache:
# maxmemory 1gb
# maxmemory-policy allkeys-lruThe normal-client output buffer setting above is explicitly unlimited; zero is not a bound. Monitor client buffers and choose tested limits if the workload needs them. The Pub/Sub setting disconnects clients that exceed its hard limit or sustain its soft limit. Redis Pub/Sub is at-most-once: messages can be lost on disconnect, so output-buffer tuning is not durable delivery. Use an appropriate persistent delivery mechanism when missed events must be recovered.
Don’t let queues and cache fight
Different logical database numbers within one Redis instance share its memory limit, eviction policy and persistence. They do not protect queue metadata from a cache eviction policy. Use separate instances for disposable cache and non-evictable queue data, with distinct Laravel connection settings. If one instance is temporarily unavoidable, use a policy that preserves critical keys and handle full-memory errors; DB indexes alone are not isolation. Separate instances on one host still share hardware and do not establish high availability.
TTL discipline and key hygiene
Set TTLs on application cache entries according to freshness needs, and plan invalidation and refresh behaviour. A shared expiry can create a cache-miss burst; test the refresh strategy. Do not add arbitrary TTLs to Laravel’s internal queue keys or assume every key should expire. Use application-specific key prefixes to avoid collisions, and keep user-dependent data correctly scoped; key naming is not access control.
Latency and disk safety
AOF everysec is a durability tradeoff: recent writes, typically around the last second, may be lost on failure. It is not a lock-correctness or zero-data-loss guarantee. Plan recovery, backup and duplicate job handling. maxmemory is not a cap on total RSS: allow space for the OS, client/replication buffers and copy-on-write during persistence operations. Redis guidance on Linux recommends disabling Transparent Huge Pages (THP) to avoid related latency and memory problems; this is not a blanket statement about all huge-page mechanisms. Review the actual platform before changing kernel settings.
PhpRedis is a separately installed PHP extension; confirm it is enabled in the CLI and FPM environments that need it. Unix sockets can be useful on one host, but do not promise a performance gain without measurement. Remote Redis adds network and authentication/TLS considerations. The Redis and Memcached guide on TTL and eviction supplies cache context; queue and lock semantics require the additional protections described here.
Evaluate Octane and Long-Lived Request State
Octane keeps the Laravel application in memory and reuses it between requests. Supported engines in Octane 2.x include FrankenPHP, RoadRunner and Swoole, each with its own prerequisites. A material framework bootstrap cost can make it worth evaluating; a slow query or remote service remains a separate bottleneck. Test the application’s correctness before comparing throughput.
What you gain, what you must change
Provider register/boot methods run when the worker boots, not as a fresh application for every request. Octane resets first-party framework state, but cannot discover every application global. Static arrays, singletons holding request/user data and stale injected request/container instances can leak state across requests. Use request-scoped bindings or explicit current-request values where appropriate and test sequential requests from different users on the same worker.
Start with supported runtime options
# Development/staging project with compatible Swoole extension installed:
composer require 'laravel/octane:^2.0'
php artisan octane:install --server=swoole
# Runtime example; supervise this process in production.
php artisan octane:start --server=swoole --host=127.0.0.1 \
--port=8000 --workers=4 --task-workers=2 --max-requests=500Install the compatible Swoole extension for the CLI PHP binary before this example; commit the dependency changes and deploy from the lock file. The four request workers and two task workers are illustrative and must fit the host’s CPU, memory and queue budget. Swoole task workers serve Octane task operations; they are not durable Horizon jobs. Put the loopback listener behind a correctly configured web server and use a process monitor in production. Do not use a nonexistent tasks key or set the config watch list to false; the supported development watcher is the --watch CLI option. Request limits/recycling reduce accumulated memory growth but do not fix state leaks.
The Octane documentation covers engine installation, web-server routing and lifecycle commands. Consult the Laravel 12 documentation for this example and verify options against the installed Octane 2.x package.
Database connections, caches, and events
Track stale database connections and open transactions across sequential requests. Use the framework’s lifecycle handling and explicit transaction cleanup in application code; do not blindly reconnect every request as a performance rule. Keep authenticated-user state out of long-lived singletons. If a cross-user response is observed, take the affected deployment out of service or roll back, then repair the state boundary and rerun the isolation test. Periodic restarts cannot make an incorrect state model safe.
Octane’s sweet spot
Octane is a candidate when framework bootstrapping remains a meaningful share after other bottlenecks are measured. Compare the same data, concurrency, cache state, error rate and p95/p99 duration with the current runtime. It is not automatically preferable for every read-heavy API, and tuning FPM does not configure Octane’s separate worker lifecycle.
Deployments, Process Lifecycles and Monitoring
A deployment must coordinate code, configuration, database schema and long-lived workers. Keep the old release available until its requests/jobs finish, and know how to restore a compatible version. Distinguish preparation, traffic switching, process refresh and health checks so that a successful command is not mistaken for a healthy application.
Warmth before traffic
Build a new release with composer install --no-dev --optimize-autoloader. These are dependency/autoloader options, not OPcache flags. Generate config/routes/views with the target environment; Laravel 12’s optimize command can also prepare event caching. Test backward-compatible migrations before switching traffic. Refresh the relevant FPM generation or start the new runtime, then warm and health-check that new generation before admitting normal traffic where the deployment topology permits. Container cache preparation must use the correct runtime environment, not bake staging values into production.
Refresh workers and preserve pending job compatibility
horizon:pause stops taking new jobs while current work finishes; it does not consume all pending old-format jobs. For incompatible job or schema changes, stop producers and let compatible old workers finish the pending jobs before switching; pausing all workers would prevent that drain. Alternatively, migrate payloads with a tested plan or retain backward compatibility. During an ordinary compatible deployment, php artisan horizon:terminate lets the process monitor start Horizon from the new release. Use php artisan queue:restart for ordinary queue:work processes and php artisan octane:reload for running Octane workers. The queue restart signal depends on an accessible configured cache. Ensure the process monitor’s stopwaitsecs exceeds the longest job plus shutdown margin, and verify the new workers actually start. These commands do not refresh FPM’s separate OPcache.
Track the signals that matter
Track FPM busy/idle workers and listen queue, request duration percentiles, errors, Redis memory/latency/write failures, queue age and completion rate, job runtime, and database query/lock time. Distinguish waiting in a queue from execution time. The Prometheus and Grafana monitoring guide explains host monitoring; application traces are needed to connect host signals to user operations.
Fast storage and honest capacity
Storage can affect logs, temporary files, database I/O and Redis persistence, while normal Redis reads come from memory. CPU-bound reports and external API calls may have different limits. Evaluate the actual storage path, quotas and latency before choosing another VPS. The NVMe VPS guide and VPS sizing for Laravel add context for those measurements.
Database: the quiet backbone
Inspect query plans and N+1 patterns before increasing server resources. Validate indexes against actual reads and write costs; cache only data with clear freshness and invalidation rules. The InnoDB tuning checklist provides database background, but its settings must be adapted to the Laravel workload and database version.
Diagnostic Scenarios and What to Verify
The following are diagnostic examples, not reports of measured customer outcomes. Each symptom has several possible causes; collect evidence before selecting the repair.
“CPU is fine but response times spike randomly”
Check FPM wait time, slowlog traces, database locks and outbound call duration in the same interval. Low CPU may mean workers are waiting elsewhere. Apply bounded connection/read timeouts to remote calls, fix the measured query problem, or explicitly queue suitable work. Verify correct responses and tail latency after the change; no single cause explains every random spike.
“Queues are always behind, even off‑peak”
Compare arrival rate, completion rate, oldest-job age and runtime by queue. Check worker availability, failed jobs, downstream rate limits and Redis dispatch errors. If cache and queues share an evicting instance, repair that correctness risk rather than assuming jobs are merely waiting. Then size concurrency within CPU/memory/downstream budgets and validate idempotency before replaying failed work.
“Octane made our app fast… and weird”
Cross-user data is evidence of a broken state boundary, not a harmless performance side effect. Reproduce sequential requests on the same worker, inspect static/singleton/request-container state, and use scoped bindings or explicit cleanup as appropriate. Also test concurrent requests and cache keys. Roll back the affected runtime until the isolation tests pass; restarting workers periodically is not a sufficient fix.
“After deploy, the first few requests are slow”
Check for cold code/data caches, slow startup, configuration mistakes and migration contention. Prepare environment-specific Laravel caches in the release; refresh the relevant runtime before warming it. Use readiness/health checks against the new generation and inspect its logs before traffic admission. Reloading FPM after warm-up defeats the intended OPcache warming.
“Memory leak or just busy?”
Compare per-worker private/PSS memory, worker count and request mix over a representative interval. Rising RSS can reflect more workers, allocator retention or shared mappings as well as a leak. A value that does not immediately fall is not proof. Reproduce sustained growth with the same workload, identify the owning code/extension and adjust concurrency or lifecycle limits while repairing the cause.
CDN and Application Cache Boundaries
Use the CDN or web server for static assets and deliberately public cacheable responses. Apply Cache-Control and bypass rules that keep authenticated, personalized and session-dependent content out of shared caches; test with different users and cookies. Faster cache hits are useful only if responses are correct. The CDN caching guide offers header and bypass examples to adapt to Laravel routes.
Validate the Complete Production Path
Size FPM and Horizon within one host budget; keep job timeouts below retry_after and make duplicate execution safe. Refresh OPcache in its actual runtime, then warm the new processes. Separate non-evictable queue data from disposable Redis cache, and plan for full-memory write failures and persistence loss. Evaluate Octane with user-isolation tests as well as performance measurements.
Record the baseline, change one justified setting and compare the same workload. Keep release, dependency versions, configuration and rollback evidence together. Recheck the plan after meaningful traffic or code changes. Reliable operation comes from verified behaviour across these layers; a configuration snippet alone cannot guarantee a speed increase or eliminate outages.





