The Booted Application Fallacy
PHP-FPM spoiled us. For twenty years, developers wrote code assuming every HTTP request started with a clean slate. You boot Laravel 12 on PHP 8.3, register bindings, fire queries, send the payload, and PHP kills the entire process. Any sloppy static property, unclosed file handle, or bloated global array disappears into the operating system garbage collector.
Laravel Octane throws that safety net away. By running your application on long-running worker processes managed by FrankenPHP, Swoole, or RoadRunner, the framework boots once. Subsequent requests hit an already-warm application in RAM. Response times for simple API endpoints drop from 45ms down to 6ms. Throughput jumps from 350 requests per second to over 2,500 on the exact same 4-vCPU server instance.
That speed is intoxicating. It also breaks fundamental assumptions in your codebase. When the worker process doesn't die, every object created inside a singleton, every static property set during execution, and every event listener registered dynamically persists across requests. What was clean code in PHP-FPM turns into a data security flaw or a memory leak under Octane.
How Shared State Leaks Between Requests
The most dangerous issue with persistent workers is shared state leakage. If User A makes a request that mutates a static class property or populates a singleton service, User B might read that mutated state two seconds later on the exact same worker process.
Here is a real example from a production codebase that caused tenant data cross-contamination:
namespace App\Services;
class TenantContext
{
protected static ?int $tenantId = null;
public static function setTenantId(int $id): void
{
static::$tenantId = $id;
}
public static function getTenantId(): ?int
{
return static::$tenantId;
}
}In standard PHP-FPM, setting TenantContext::setTenantId(42) during a request was harmlessly isolated. Once the response left the server, the process died and $tenantId reverted to null. Under Octane, worker process #3 keeps $tenantId = 42 stored in RAM. If an unauthenticated or public request hits worker #3 next, TenantContext::getTenantId() still returns 42.
To fix shared state in Octane, you have two choices. First, avoid static properties entirely for request-specific state. Second, if you must use singletons or custom state containers, register them in Octane's flush list inside config/octane.php:
'flush' => [
App\Services\TenantContext::class,
],Octane will reset or re-instantiate listed classes after every request handling cycle. But relying heavily on the flush configuration array is a crutch. The cleaner approach is designing services as stateless objects that accept context via method arguments rather than internal state properties.
Memory Growth and Zombie Workers
In PHP-FPM, memory leaks don't matter much unless a single request exceeds your memory_limit directive. A script leaking 2MB on every run gets wiped clean when the process exits. Under Octane, a 2MB leak per request consumes 2GB of system RAM after 1,000 requests. Eventually, the operating system out-of-memory killer steps in and abruptly kills your worker process, dropping active user connections.
Memory growth usually comes from three places in Laravel applications:
- Static arrays that grow without limits: In-memory caches, collection buffering, or custom logger arrays that collect items during execution without an upper size bound.
- Unbound event listeners: Registering anonymous functions or closures to event dispatchers inside request pipelines. Each incoming request appends another closure to memory.
- Circular object references: Eloquent models holding references to relations that point back to parent models, preventing PHP's reference counter from freeing memory until full cycle collection triggers.
We ran tests on Laravel 12 workers running FrankenPHP under heavy load. A poorly configured service appending request telemetry to an internal array pushed worker memory from 38MB at boot to 310MB after 4,000 requests. The worker didn't crash immediately, but response latency spiked from 8ms to 85ms because PHP spent increasing CPU cycles in gc_collect_cycles() calls.
You can mitigate worker memory bloat by configuring worker request limits in config/octane.php or passing options directly to the server binary. Setting --max-requests=1000 forces Octane to recycle worker processes gracefully after 1,000 requests, returning memory back to the system without dropping traffic.
What Breaks Under Octane
Beyond state leaks and memory growth, several standard Laravel patterns break when shifting from stateless HTTP execution to persistent workers. Here are three common traps.
1. Injecting Request into Service Singletons
Registering a singleton service inside a ServiceProvider that resolves or accepts the current HTTP request object creates a major bug. The container resolves the singleton on worker boot, binding the first HTTP request that triggered instantiation to that singleton forever.
namespace App\Providers;
use Illuminate\Support\ServiceProvider;
use App\Services\AuditLogger;
class AppServiceProvider extends ServiceProvider
{
public function register(): void
{
// DANGEROUS UNDER OCTANE: Captures the initial request instance permanently
$this->app->singleton(AuditLogger::class, function ($app) {
return new AuditLogger(
$app['request']->ip(),
$app['request']->header('User-Agent')
);
});
}
}Every subsequent user calling AuditLogger gets the IP address and User-Agent header of whichever user happened to trigger worker boot. To fix this, pass the Request instance into the specific method calls at execution time, or use container closures that evaluate dynamically on every call.
2. Mutating Global Configuration at Runtime
Calling config(['app.timezone' => 'UTC']) during request execution alters the configuration repository stored in worker RAM. That configuration change persists for every future request handled by that worker. If user preferences dynamically switch config values (such as currency formats or mail gateways), other users on the same worker inherit those modified values unexpectedly.
3. Stale Database Connections and Cache Client Disconnects
Long-running processes face dropped TCP connections. MySQL's wait_timeout or PostgreSQL's idle connection timeout will drop socket connections if a worker sits idle during low-traffic periods. Octane handles standard PDO reconnection automatically, but custom Redis or third-party SDK connections using raw sockets frequently throw errors unless configured with ping mechanisms or explicit reconnect logic.
When Is Octane Worth It?
Don't run Octane just because it looks fast on synthetic benchmarks. If your application handles 100 requests per minute for an internal dashboard, standard PHP-FPM with OPCache enabled on PHP 8.3 is faster than your database queries anyway. You gain nothing by risking state leakage bugs for a 30ms latency reduction that your users won't notice.
Octane shines when building high-concurrency APIs, real-time microservices, or backend services powering Next.js 16 and React 19 frontends making dozens of parallel API calls per page render. At 2,000+ requests per second, running FrankenPHP via Octane reduces server instances from ten nodes down to three. That cuts infrastructure costs by over 60% while delivering sub-10ms response times.
If you choose Octane, pick FrankenPHP over Swoole for modern Laravel 12 setups. FrankenPHP integrates directly with Caddy, handles worker restarts gracefully, supports HTTP/3 out of the box, and doesn't require compiling C extensions manually. Just make sure your test suite runs with worker persistence enabled so state leaks catch build failures before reaching production.














