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
Source: Laravel — Server Prerequisites
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
Source: Laravel — FrankenPHP via Docker
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
Source: Laravel — Octane
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"}
Source: Laravel — Request Injection
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
Source: Laravel — Managing Memory Leaks
--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:
--workersdefaults 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:reloadrestarts the workers with the new code. Without it, the old release keeps running indefinitely.php artisan reloadincludes it. -
Process manager:
octane:startruns 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
rrmanaging PHP workers) is the alternative without PHP extensions. Swoole and Open Swoole (C extensions) addOctane::concurrently(), ticks (Octane::tick()), the in-memory Octane cache and shared tables, and they tie the application to that extension. -
Long requests:
max_execution_timeinconfig/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,wrkorabagainst 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. TheConcurrencyfacade and Redis work with every server (Processes, Concurrency and Context).