+1 (415) 599-8902

Running an Existing Laravel App on Octane and FrankenPHP

Most Laravel applications spend a measurable slice of every request booting the framework: loading the container, registering service providers, reading config, resolving routes — then throwing all of it away. Octane keeps the booted application in memory in a long-lived worker process and reuses it across requests. On a typical CRUD-heavy app we usually see p95 response times drop by 40–60% with no application changes at all, and much more on endpoints that were framework-bound rather than database-bound.

The catch: your code now runs in a process that does not die between requests. State that used to be cleaned up for free by PHP's shared-nothing model now persists, and a small number of long-standing habits become bugs. This tutorial takes an existing Laravel 13 application, runs it under Octane with the FrankenPHP server, and works through the patterns that actually break.

Choosing a server

Octane can drive three runtimes:

  • FrankenPHP — a Go-based server with PHP embedded, HTTP/2 and HTTP/3, automatic HTTPS, and native support for early hints and static file serving. It is the default in current Laravel docs and the one we reach for on new work.
  • Swoole — a PHP extension with the richest feature set: concurrent tasks, ticks, and a built-in cache table. Requires compiling or installing the extension.
  • RoadRunner — a Go application server, a good middle ground if you cannot install extensions but want a mature runtime.

Everything below applies to all three; the setup commands are FrankenPHP.

Install

composer require laravel/octane
php artisan octane:install --server=frankenphp

The installer downloads the FrankenPHP binary and publishes config/octane.php. Start it locally:

php artisan octane:start --server=frankenphp --watch --workers=4

--watch restarts workers when files change (it needs chokidar: npm install --save-dev chokidar). Never use --watch in production.

Point a load test at a boring endpoint before and after — hey -z 30s -c 20 http://127.0.0.1:8000/dashboard or similar. Record the numbers. You want evidence, not vibes, when you tell a client the migration was worth it.

What actually changes

Under PHP-FPM every request gets a fresh process: fresh container, fresh statics, fresh globals. Under Octane, the framework is booted once per worker and each request is handled inside that same process. Octane flushes a known set of things between requests (the request/response, session, auth state, the database's query log) and re-resolves a configurable list of container bindings. Everything else you put in memory stays there.

That produces four categories of bug.

1. Singletons that capture the request

The classic leak. A singleton resolved during the first request holds onto that request's data forever:

// Broken under Octane
$this->app->singleton(Reporter::class, function ($app) {
    return new Reporter($app['request']->input('tenant'));
});

The second request gets the first request's tenant. Inject what you need at call time instead:

$this->app->singleton(Reporter::class, fn () => new Reporter);

// in the class
public function tenantFor(Request $request): string
{
    return $request->input('tenant');
}

If a third-party package does this and you cannot change it, add the binding to octane.flush in config/octane.php so Octane forgets it between requests. That costs you the singleton's benefit but keeps it correct.

The same rule applies to service providers: anything registered in register() or boot() runs once per worker, not once per request. Do not read request(), the authenticated user, or the current tenant there.

2. Static properties and in-memory caches

class ExchangeRates
{
    protected static array $rates = [];   // shared by every request this worker handles
}

Sometimes that is exactly what you want — a genuinely global, immutable lookup table cached in memory is a nice Octane win. Usually it is an accident, and it becomes a cross-tenant data leak the moment the cached value is per-user. Audit every static property that gets written to at runtime. If the value is per-request, move it onto a request-scoped object; if it is per-tenant, key it by tenant ID and set a bound; if it is global and immutable, keep it and enjoy the free cache.

Octane's Octane::table() (Swoole) or the ordinary cache driver are safer places for shared state you actually want.

3. Config, env and Auth::user() at boot

env() returns null once config is cached, and under Octane config caching is effectively mandatory. Read from config() in application code, and only from env() inside config/*.php files. This rule already existed; Octane makes breaking it fail consistently instead of intermittently.

Similarly, a listener or middleware that memoises Auth::user() into a property on a singleton will serve the wrong user. Octane clears the auth guard state between requests, but only for objects it knows about.

4. Memory growth

Long-lived workers expose leaks that never mattered before: a growing static array, an event listener registered per request, a package that accumulates handlers. Two defences:

// config/octane.php
'max_execution_time' => 30,
'garbage' => 50,        // MB of growth after which the worker restarts

and, in production, --max-requests=500 so each worker recycles regularly. Recycling is not a fix for a leak, but it stops one from taking the site down while you find it.

Watch memory per worker in your metrics. A healthy worker plateaus; a leaking one climbs in a straight line.

Finding the problems before your users do

Three practical steps we run on every migration:

Grep for the smells.

grep -rn "static \$" app/
grep -rn "->singleton(" app/Providers/
grep -rn "env(" app/ routes/ resources/

Every hit is a question to answer, not necessarily a bug.

Run the test suite against Octane. Add a workflow step that boots octane:start and runs your HTTP tests through the real server rather than the in-process kernel. Bugs of this class only appear on the second request, so make sure any test that matters hits the endpoint twice with different data:

it('does not leak tenant state between requests', function () {
    $a = Tenant::factory()->create(['name' => 'Acme']);
    $b = Tenant::factory()->create(['name' => 'Globex']);

    $this->withHeader('X-Tenant', $a->id)->get('/api/whoami')
        ->assertJsonPath('tenant', 'Acme');

    $this->withHeader('X-Tenant', $b->id)->get('/api/whoami')
        ->assertJsonPath('tenant', 'Globex');
});

Deploy to a canary. Run one Octane instance behind the load balancer alongside your FPM instances for a day and compare error rates and memory before shifting the rest.

Features you get in return

Once the app is stable under Octane, the runtime gives you things FPM cannot:

Concurrent work inside a request. Fan out independent I/O and wait for all of it:

use Laravel\Octane\Facades\Octane;

[$user, $orders, $rates] = Octane::concurrently([
    fn () => $api->user($id),
    fn () => $api->orders($id),
    fn () => $rates->latest(),
]);

Three sequential 200ms calls become one 200ms wait.

Deferred work without a queue. Octane::tick() and background tasks let you push non-critical work (writing an audit row, warming a cache) after the response is sent. Use the queue for anything that must not be lost — a worker restart discards deferred work.

Cheap route-level caching. With boot cost removed, the remaining time is your code and your database. That is the point: Octane makes your real bottlenecks visible instead of hiding them behind 80ms of framework boot.

Deploying it

Under systemd or Docker, run php artisan octane:start --server=frankenphp --host=0.0.0.0 --port=8000 --workers=auto --max-requests=500 as the container command and put your load balancer in front. On Laravel Cloud and Forge, Octane is a toggle plus a deploy-script change.

The one non-negotiable deployment step: reload workers after every deploy.

php artisan config:cache
php artisan route:cache
php artisan event:cache
php artisan octane:reload

Without octane:reload, workers keep serving the code they booted with and your deploy silently does nothing. Add it to the end of the deploy script the same day you enable Octane, not the first time it confuses someone.

Is it worth it?

Octane is worth it when framework boot is a real share of your response time — API-heavy applications, high-traffic marketing endpoints, anything where you are paying for CPU by the request. It is not worth it when your p95 is 900ms because of an N+1 query; fix the query first, because Octane will not save you and the migration will just add operational surface.

Measure first, migrate on a canary, and treat the state-leak audit as the actual work. The install is ten minutes; the audit is the project.

If you would like a second pair of eyes on a Laravel application before or after an Octane migration, our Laravel performance optimization and deployment engineering teams do this work every week — get in touch.