Octane and Performance

This content was generated with the assistance of AI and should be verified against the official documentation before being relied on in production.

The reference material consulted while preparing these pages is listed in the section bibliography.

This page is written against Laravel 13.x (13.34.0) on PHP 8.3 to 8.5, checked on 2026-10-05, with laravel/octane 2.20.0 and FrankenPHP 1.13.0 on PHP 8.5.11. A classic PHP request boots the whole framework, handles the request and throws everything away. Octane boots the application once per worker and then handles request after request in the same process. That removes most of the per-request cost, and it brings back a problem PHP was designed not to have: state that survives from one request to the next. The two models are compared on PHP Request Model and Servers.

Running Octane on FrankenPHP

composer require laravel/octane and php artisan octane:install --server=frankenphp set it up, and php artisan octane:start runs it. In the official dunglas/frankenphp:php8.5 image, octane:start stopped at once:

Error
Undefined constant "Laravel\Octane\Commands\Concerns\SIGINT"
at vendor/laravel/octane/src/Commands/Concerns/InteractsWithServers.php:174

The octane:start command handles signals and needs the pcntl extension, which the image does not include. In a normal build, install-php-extensions pcntl in the Dockerfile adds it (Docker and Sail). That needed network access, which docker build did not have here. FrankenPHP itself does not need pcntl, so the server was started the way octane:start would start it: frankenphp run with Octane’s Caddyfile and worker script, and the environment variables the command sets:

$ cp vendor/laravel/octane/src/Commands/stubs/frankenphp-worker.php public/
$ docker run -v "$PWD":/srv/bookshelf -w /srv/bookshelf \
    -e APP_BASE_PATH=/srv/bookshelf -e APP_PUBLIC_PATH=/srv/bookshelf/public -e LARAVEL_OCTANE=1 \
    -e MAX_REQUESTS=100 -e CADDY_SERVER_WORKER_DIRECTIVE='num 2' -e CADDY_SERVER_SERVER_NAME='http://:8000' \
    -e CADDY_GLOBAL_OPTIONS='auto_https disable_redirects' … \
    dunglas/frankenphp:php8.5 frankenphp run -c vendor/laravel/octane/src/Commands/stubs/Caddyfile

What it gains

Three hundred requests to each server after 20 warm-up requests, from the same image and the same code, in the local environment without configuration caches:

classic php-server   median  14.7 ms, p95  18.9 ms    /books (two queries and a Blade view)
Octane worker mode   median   5.1 ms, p95   6.5 ms
classic /up          median  10.6 ms, p95  17.1 ms    (boots the framework, does almost nothing)
Octane /up           median   2.0 ms, p95   3.6 ms

The /up route shows the cost of booting: about 8.6 ms of the classic 10.6 ms went to starting the framework, and Octane skipped it. For /books the gain was 9.6 ms of 14.7. A page that spends 300 ms in the database gains the same few milliseconds, which is about 3%. Octane helps fast pages under heavy traffic. It does not help a slow page, which needs the work on Query Performance and N+1 instead. php artisan optimize and OPcache recovered part of the boot cost without Octane, as measured on Deployment.

State that leaks between requests

Octane gives each request a fresh copy of the container, so a singleton resolved during a request is gone at the next one. Two routes show where that protection ends:

<?php
// AppServiceProvider::register()
$this->app->singleton(ReaderGreeting::class, fn ($app) => new ReaderGreeting($app['request']->query('reader', 'guest')));
$this->app->scoped('reader.scoped', fn ($app) => new ReaderGreeting($app['request']->query('reader', 'guest')));

// config/octane.php, in the returned array:
// 'warm' => [
//     ...Octane::defaultServicesToWarm(),
//     App\Services\ReaderGreeting::class,      // resolved once per worker: captures the boot-time request
// ],

// routes/web.php
Route::get('/octane/greet', fn (ReaderGreeting $greeting) => [
    'singleton' => $greeting->text(),
    'scoped' => app('reader.scoped')->text(),
    'request' => request('reader', 'guest'),
]);
Route::get('/octane/leak', function () {
    static $cache = [];                               // survives between requests in a worker
    $cache[] = str_repeat('x', 1024 * 1024);
    return ['entries' => count($cache), 'memory_mb' => round(memory_get_usage(true) / 1048576)];
});

Before the warm entry was added, the singleton answered correctly for every reader, because it was resolved inside each request’s copy of the container. With the entry, it was built once when the worker booted:

{"singleton":"Hello guest","scoped":"Hello ada","request":"ada"}
{"singleton":"Hello guest","scoped":"Hello ben","request":"ben"}
{"singleton":"Hello guest","scoped":"Hello cleo","request":"cleo"}

The warmed singleton kept the request from boot time, which had no reader, so every visitor was greeted as guest. With a user instead of a query string, every visitor would see the first user’s data. The same happens to any object resolved at boot, or stored in a static property, that holds the request, the authenticated user, the container or a configuration value that a later request changes. The fixes are to inject Request into the method rather than the constructor, to use scoped(), to pass a closure that returns the value when called, and to never put request data in statics. The documentation’s sections on container, request and configuration injection list the cases.

The static array is the other leak. Each request added 1 MB to the same worker:

request 1:  {"entries":1,"memory_mb":14}
request 59: {"entries":58,"memory_mb":128}
request 60: HTTP 500                       the 128 MB memory_limit reached
request 61: {"entries":2,"memory_mb":16}   a new worker
errors at requests 60, 129 and 196 of 230

--max-requests=100 would have restarted the worker after 100 requests, but memory_limit (128 MB) killed it first. The request that hit the limit failed for its user. max-requests limits slow leaks. It does not prevent a fast one, and the error log would show the failing request but not where the memory went. Octane’s flush list in config/octane.php resets bindings that a package caches, and Octane::defaultServicesToWarm() already lists the framework services that are safe to keep.

Running in production

  • Workers: --workers defaults to the CPU count. Each worker holds one booted application (14 MB in Bookshelf at its first request, more as caches fill), so the worker count times the memory must fit the container.

  • Deploys: php artisan octane:reload restarts the workers with the new code. Without it, the old release keeps running indefinitely. php artisan reload includes it.

  • Process manager: octane:start runs under Supervisor or systemd like Horizon. Behind Nginx, it is proxied over HTTP. FrankenPHP can also serve HTTPS itself.

  • Server choice: FrankenPHP is the documented default, a single binary that serves static files and HTTPS. RoadRunner (Go, with rr managing PHP workers) is the alternative without PHP extensions. Swoole and Open Swoole (C extensions) add Octane::concurrently(), ticks (Octane::tick()), the in-memory Octane cache and shared tables, and they tie the application to that extension.

  • Long requests: max_execution_time in config/octane.php (30 seconds by default) ends a request that runs too long. A worker blocked on one slow call serves nobody else meanwhile.

Packages that keep state in statics, such as some debug bars and older SDKs, can break under Octane. A test that sends two requests for different users through the same worker, in staging, catches the leaks above.

OPcache, preloading and JIT

OPcache is enabled in the official images, and in production it should run with opcache.validate_timestamps=0, which needs a reload on deploy (Deployment). Preloading (opcache.preload) loads chosen files into shared memory at server start, so classic FPM requests skip compiling them. Laravel ships no preload script, and with Octane the boot already happens once, so preloading mainly helps FPM. The JIT (opcache.jit=tracing) speeds up CPU-bound PHP, such as image processing or long calculations. Typical web requests wait on I/O and gain little. All three only help after the database queries are right.

Measure first

Optimisation starts with finding where the time goes:

  • Per request: Telescope, Debugbar or Nightwatch show queries and their time (Monitoring and Debugging).

  • Per function: the Xdebug profiler (xdebug.mode=profile, opened in KCachegrind or PhpStorm), SPX, or Blackfire show which functions use the CPU. Profile with production-like data, because N+1 queries do not appear with ten rows.

  • Under load: a load generator such as k6, wrk or ab against a production-mode build gives the median and p95 above. A single request in development says little.

The numbers on this page came from a Python loop of sequential requests against containers on one machine. They show the relative difference. They are not capacity figures.

Where older books and tutorials are outdated

  • "PHP is slow because it boots every request" has been answered in two ways since PHP 7: OPcache and the faster engine made each boot cheap, and Octane (2021) removes it.

  • Swoole as the only way to run Laravel in memory changed when FrankenPHP and RoadRunner support arrived. FrankenPHP is now the documented default.

  • Caching everything in statics for speed is the leak shown above once a worker serves many requests.

  • Swoole-only features used as general patterns (Octane::concurrently, the Octane cache) tie an application to one server. The Concurrency facade and Redis work with every server (Processes, Concurrency and Context).