PHP 8.5 Performance Tuning for Laravel APIs: OPcache, FPM, and Real-World Bottlenecks

March 19, 2026

You upgraded to PHP 8.5, deployed your Laravel API, and response times look… about the same. That’s normal. Language-level improvements only take you so far. The real throughput gains come from how you configure the runtime around your application — OPcache behavior, FPM process management, and identifying the bottlenecks that actually matter in production.

This guide walks through the server-side tuning that makes PHP 8.5 perform the way you expected it to under real API workloads.

Establish a Baseline Before You Touch Anything

Tuning without measurement is guesswork. Before changing any configuration, capture baseline numbers for your API under realistic conditions.

What to measure:

  • Requests per second at your expected concurrency level (not peak — your normal Tuesday afternoon)
  • P95 and P99 response times for your most-hit endpoints
  • Memory usage per FPM worker under sustained load
  • OPcache hit rate via opcache_get_status() or a monitoring dashboard
  • Database query count per request for your top 5 endpoints

Run your baseline against a staging environment that mirrors production hardware. Use a load-testing tool like k6, Locust, or even ab — the tool matters less than consistency between runs. Record the numbers somewhere you can reference later, because you’ll be comparing against them after every change.

A quick sanity check: hit GET /api/health (or any minimal endpoint) under load first. This gives you the framework overhead floor — the fastest your application can possibly respond when it’s doing almost nothing. If that floor is already high, your bottleneck is infrastructure, not application code.

OPcache Settings That Actually Matter

PHP 8.5 ships with OPcache improvements to JIT warmup and interned string handling, but the defaults are conservative. For a Laravel API that serves thousands of requests per minute, you need to adjust a few values.

Memory and buffer sizing

opcache.memory_consumption=256
opcache.interned_strings_buffer=64
opcache.max_accelerated_files=20000

memory_consumption controls the shared memory pool for compiled scripts. Laravel applications with dozens of packages easily exceed the 128MB default once you account for all vendor files. Set this to 256MB and monitor actual usage — if you’re consistently above 80% utilization, bump it higher.

interned_strings_buffer stores deduplicated strings across all cached scripts. Laravel’s heavy use of string-based configuration, route names, and Eloquent attribute keys means this buffer fills up faster than in minimal frameworks. 64MB is a reasonable starting point for medium-to-large applications.

max_accelerated_files caps how many PHP files can be cached. A typical Laravel project with dependencies can have 10,000–15,000 files. Set this above your actual file count (check with find vendor -name "*.php" | wc -l) and round up generously.

Revalidation and timestamps

opcache.validate_timestamps=0
opcache.revalidate_freq=0

In production, set validate_timestamps=0. This tells OPcache to never check whether source files have changed on disk, which eliminates a stat call per file per request. The tradeoff is that you must restart FPM (or call opcache_reset()) after every deployment. This is standard practice in containerized environments where you’re deploying new images anyway.

If you’re deploying via symlink swaps (Envoyer, Deployer), keep validate_timestamps=1 but set revalidate_freq=60 or higher to reduce stat overhead without requiring manual cache clears.

Preloading

opcache.preload=/var/www/app/preload.php
opcache.preload_user=www-data

PHP 8.5’s preloading loads specified classes into shared memory at FPM startup, so they’re available to every request without compilation or autoloading. Laravel ships with an artisan command to generate a preload file:

php artisan optimize

The impact of preloading varies. For API-heavy applications where the same service classes handle most requests, preloading can reduce per-request autoload overhead meaningfully. For applications with many rarely-used classes, the memory tradeoff may not be worth it. Measure both ways.

JIT configuration

opcache.jit_buffer_size=128M
opcache.jit=tracing

PHP 8.5 refined the tracing JIT, particularly around type inference for repeated call patterns — exactly the kind of workload a Laravel API controller produces. Set jit=tracing (not function) for API workloads, as tracing mode optimizes hot paths across function boundaries.

The JIT helps most with CPU-bound work: heavy data transformations, complex business logic, or response serialization. It won’t speed up I/O-bound operations like database queries or HTTP calls. If your API is mostly a CRUD wrapper, JIT gains will be modest.

PHP-FPM Process Manager Sizing

FPM configuration is where many teams leave significant throughput on the table. The defaults are designed for shared hosting, not dedicated API servers.

Choosing a process manager

pm = static
pm.max_children = 50

For dedicated API servers with predictable traffic, use pm = static. This pre-forks all workers at startup and keeps them running. There’s no process creation overhead during traffic spikes, and memory usage is predictable.

Use pm = dynamic or pm = ondemand only when the server also handles other workloads and you need FPM to yield resources when idle. For Kubernetes pods or dedicated API instances, static is almost always the right choice.

Calculating max_children

The formula is straightforward:

max_children = available_memory / average_memory_per_worker

To find your average worker memory, run your API under realistic load for several minutes, then check:

ps -eo pid,rss,comm | grep php-fpm | awk '{sum+=$2; n++} END {print sum/n/1024 " MB average"}'

Typical Laravel API workers consume 30–60MB each depending on the application. On a server with 4GB available for FPM, that gives you roughly 65–130 workers. Start conservative, monitor under load, and adjust.

Setting max_children too high causes memory pressure and swapping, which is dramatically worse than queuing a few requests. Too low means you’re leaving CPU idle while requests wait for a free worker.

Request lifecycle settings

pm.max_requests = 1000
request_terminate_timeout = 30

pm.max_requests recycles workers after N requests, which guards against memory leaks in long-running workers. 1000 is a reasonable default. Set it lower (200–500) if you suspect leaks in specific packages, higher (5000+) if your memory profile is flat.

request_terminate_timeout kills workers that exceed this duration. For APIs, 30 seconds is generous — most API requests should complete in under 1 second. This prevents a single slow request from permanently consuming a worker.

Slow Queries and the N+1 Reality

No amount of OPcache or FPM tuning will fix an endpoint that runs 200 database queries per request. After server-level configuration, database interaction is almost always the next bottleneck.

Finding N+1 queries

Install Laravel Debugbar or Telescope in your staging environment and exercise your API endpoints. Look for endpoints where the query count scales with the result set size — that’s the N+1 pattern.

Common hiding spots in Laravel APIs:

  • API Resources that access relationships without explicit eager loading
  • Policies and Gates that load related models to check permissions
  • Event listeners that query the database in response to Eloquent events
  • Accessors that call other relationships behind a computed attribute

Use preventLazyLoading() in your AppServiceProvider during development:

Model::preventLazyLoading(!app()->isProduction());

This throws an exception whenever a relationship is lazy-loaded, forcing you to add explicit with() calls where they’re needed.

Slow query identification

Enable MySQL’s slow query log (or the equivalent for PostgreSQL) with a threshold of 100ms. For an API that should respond in under 200ms, any single query taking 100ms is consuming half your budget.

Common culprits in Laravel:

  • Missing indexes on columns used in where, orderBy, or join clauses
  • Full table scans on growing tables (check with EXPLAIN on your heaviest queries)
  • Unoptimized whereHas subqueries that can often be replaced with joins
  • Sorting on non-indexed columns in paginated endpoints

Use DB::listen() in a service provider to log queries exceeding your threshold in staging:

DB::listen(function ($query) {
    if ($query->time > 100) {
        Log::warning('Slow query', [
            'sql' => $query->sql,
            'time' => $query->time,
            'bindings' => $query->bindings,
        ]);
    }
});

A Load-Testing Checklist Before You Ship

Before deploying tuning changes to production, validate them under load in staging. Here’s a checklist:

  • Reproduce your production dataset size. Query performance on a database with 1,000 rows tells you nothing about behavior at 1,000,000 rows. Seed staging with representative data volumes.
  • Test at sustained concurrency, not just peak. Run your load test for at least 5 minutes at your expected request rate. Short bursts hide memory leaks and connection pool exhaustion.
  • Monitor FPM worker status during the test. Watch for workers stuck in “active” state — that indicates requests are backing up. Use the FPM status page (pm.status_path = /fpm-status) to see this in real time.
  • Check OPcache utilization. Verify your cache hit rate is above 99% and that you have headroom in the memory pool. A cache that fills up during warm-up will start evicting entries and recompiling scripts under load.
  • Watch for connection pool limits. Laravel’s database connection limit defaults to one connection per worker. If you have 50 FPM workers, your database needs to handle at least 50 concurrent connections. Add connections for queue workers and scheduled tasks.
  • Compare P95/P99 latency, not just averages. Average response time hides tail latency. A P99 that’s 10x your P50 often indicates a resource contention issue that only appears under load.
  • Test your cache layer under load. Redis or Memcached connection limits and memory can become bottlenecks when every FPM worker is hitting them concurrently.

Rolling Out Tuning Changes Safely

Tuning changes affect every request on the server, so deploy them incrementally.

Step 1: Deploy to a single instance first. If you’re behind a load balancer, update one server’s configuration and monitor it for at least an hour under production traffic. Compare its error rate and latency against the unchanged instances.

Step 2: Watch memory closely for the first 24 hours. Some memory issues only appear after thousands of worker recycles. Monitor RSS per worker and total FPM memory consumption. If memory creeps upward between recycles, your max_requests value may need adjustment.

Step 3: Roll forward gradually. Update 25% of your fleet, then 50%, then 100%. At each stage, compare metrics against your baseline. If any stage shows regression, roll back that instance and investigate.

Step 4: Document what you changed and why. Future you (or the next person on-call) needs to know why max_children is set to 80 instead of the default. Add comments directly in your php-fpm.conf and php.ini files explaining the reasoning.

Step 5: Revisit quarterly. Traffic patterns change, application code evolves, and the optimal configuration drifts over time. Schedule a quarterly review of FPM and OPcache metrics to catch drift before it becomes a performance incident.

The Tuning Mindset

The most important thing about PHP performance tuning is the sequence: measure, change one thing, measure again. Changing OPcache, FPM, and database configuration simultaneously makes it impossible to attribute improvements (or regressions) to specific changes.

PHP 8.5 gives you a faster runtime, but the runtime is only one layer. The real gains come from understanding where your specific application spends its time — and then tuning the layer that’s actually the bottleneck. Start with a baseline, make targeted changes, and let the numbers tell you whether you’re done.


Published by Artiphp who lives and works in San Francisco building useful things.