Handling SSE Streams Under Laravel Octane: FrankenPHP, Swoole, and Process Persistence
Long-lived SSE connections behave differently under Octane's persistent workers. Managing buffer state, connection detection, and memory across FrankenPHP and Swoole runtimes.
Running SSE Streams Under Laravel Octane
In the previous post, I built an SSE streaming service in PHP, covering output buffer control, dual-layer flushing, and connection detection under PHP-FPM.
That implementation works, but it assumes the traditional PHP lifecycle: one request, one process, process dies at the end.
Laravel Octane changes that assumption. Under Octane, worker processes persist across requests. Buffer settings bleed into the next request. Connection detection APIs differ between runtimes. Memory accumulates if you're not careful.
This post covers the specific challenges of running long-lived SSE streams under Octane with FrankenPHP and Swoole.
The Persistence Problem
Under PHP-FPM, every request gets a fresh process. You can ini_set() whatever you want, disable all output buffers, and never clean up โ the process dies when the request ends.
Octane doesn't work that way. A single worker serves hundreds or thousands of requests sequentially. Any state you mutate persists.
// Request 1: SSE stream disables buffering
@ini_set('output_buffering', 'Off');
@ini_set('zlib.output_compression', '0');
// Request 2: Normal HTML page โ buffering is STILL off
// Response gets sent in tiny chunks, performance tanksThis is why the snapshot-and-restore pattern is essential under Octane:
private function stream(callable $statusProvider, callable $formatter): void
{
$originalSettings = $this->bufferingManager->disable();
try {
$this->executeLoop($statusProvider, $formatter);
} finally {
// This MUST run, even if the stream throws
$this->bufferingManager->restore($originalSettings);
gc_collect_cycles();
}
}The finally block isn't defensive programming โ it's a hard requirement.
Without it, every request after an SSE stream on that worker will have broken buffering until the worker is recycled.
Connection Detection Across Runtimes
Under FPM, connection_aborted() reliably detects dead clients after a flush.
Under Octane, behavior depends on the runtime.
FrankenPHP: The Write Probe
FrankenPHP embeds PHP inside a Go HTTP server. PHP writes to Go's response writer via STDOUT.
The Go side manages the actual TCP connection, so PHP's connection_aborted() doesn't always reflect the true connection state.
The solution is a zero-byte write probe:
private function checkFrankenPhpConnection(): bool
{
$written = @fwrite(STDOUT, '');
return $written === false;
}Writing an empty string to STDOUT doesn't send any data to the client. However, fwrite() returns false if Go's response writer has detected a broken pipe.
This becomes a zero-cost probe:
- No bytes sent to the client
- No visible side effects
- Real connection state from Go's layer
@ suppression prevents warnings if STDOUT is already closed.
Swoole: Native Connection Tracking
Swoole manages its own event loop and TCP connection pool in its C extension.
It does not use PHP's stream wrappers, which means connection_aborted() is meaningless.
private function checkSwooleConnection(): bool
{
$response = $this->app->make('swoole.response');
if ($response !== null && method_exists($response, 'isWritable')) {
return !(bool) $response->isWritable();
}
return false;
}Swoole's isWritable() checks the connection state directly from Swoole's internal connection table.
No flush required. No write probe required. It reflects the real TCP state immediately.
The Multi-Layer Detector
Since we need to support FPM (development), FrankenPHP (production), and potentially Swoole, the connection detector checks multiple layers:
final readonly class ConnectionDetector implements ConnectionDetectorInterface
{
public function __construct(
private Application $app
) {}
public function isDisconnected(): bool
{
// Layer 1: PHP's built-in check
if (connection_aborted() !== 0) {
return true;
}
// Layer 2: Swoole connection tracking
if ($this->app->bound('swoole.server')) {
return $this->checkSwooleConnection();
}
// Layer 3: FrankenPHP probe
if ($this->app->bound('frankenphp.response')) {
return $this->checkFrankenPhpConnection();
}
return false;
}
}The ordering matters.
connection_aborted() runs first because it's cheap โ it just reads an internal flag.
Runtime-specific checks only run if the application is bound to that runtime.
- Under FPM โ only layer 1 runs
- Under FrankenPHP โ layers 1 and 3 run
Singleton vs Scoped Bindings
Octane's IoC container has two relevant binding types for long-lived services:
// Process-global instance shared across requests
$this->app->singleton(
BufferingManagerInterface::class,
BufferingManager::class
);
// Per-request instances
$this->app->scoped(
ConnectionDetectorInterface::class,
ConnectionDetector::class
);
$this->app->scoped(
SseStreamInterface::class,
SseStreamService::class
);Buffering Manager
The buffering manager is a singleton because ini_get() and ini_set() operate on process-global PHP settings.
Creating a new instance per request would still read the same global values.
Connection Detector
The connection detector must be scoped, because each request has its own connection.
If it were a singleton, a disconnected client on request N could poison the connection state for request N+1.
Stream Service
The SSE stream service is also scoped because it tracks per-stream state:
- iteration count
- last stage
- start time
Getting this wrong leads to subtle bugs that only appear under load.
Memory Management in Long-Lived Workers
Under FPM, memory leaks are less dangerous because the process dies after the request.
Under Octane, memory accumulates across requests.
An SSE stream that runs for 120 seconds and leaks a few KB per iteration will gradually degrade the worker.
The stream loop addresses this with periodic garbage collection:
// Every 20 iterations (~10 seconds at 500ms intervals)
if ($iteration % self::GC_INTERVAL === 0) {
gc_collect_cycles();
}gc_collect_cycles() walks PHP's reference graph and frees objects trapped in circular references.
PHP's automatic GC triggers when the root buffer exceeds a threshold (10,000 by default). In a tight loop, that threshold might not trigger during the stream's lifetime.
The final cleanup ensures a clean worker:
finally {
$this->bufferingManager->restore($originalSettings);
gc_collect_cycles();
}The X-Accel-Buffering Header
One easy-to-miss detail: nginx buffers responses by default.
An SSE stream without the proper header will appear to hang because events accumulate in nginx's proxy buffer.
X-Accel-Buffering: noThis header instructs nginx to forward chunks immediately.
Without it, your flushed events will sit in nginx until:
- the buffer fills, or
- the connection closes
Debugging SSE Under Octane
SSE issues under Octane are difficult to debug because they are often timing-dependent and worker-specific.
Test with curl first
Browsers implement their own reconnection logic that can hide issues.
curl -N -H "Accept: text/event-stream" \
http://localhost/api/orders/stream/test-tokenThe -N flag disables curl buffering so you can see events as they arrive.
Watch worker limits
Each SSE stream occupies a worker.
If all workers are busy streaming, normal requests will queue.
Log buffer levels
If events arrive late or in bursts, temporarily log ob_get_level() before and after buffer disabling.
If the level does not reach 0, something is re-enabling buffers:
- middleware
- error handlers
- Octane response handling
Lessons Learned
Octane makes PHP stateful.
Every INI change, global variable, and singleton property survives the request lifecycle.
Treat SSE services like connection-handling logic in Node.js or Go servers โ always clean up after yourself.
Connection detection is runtime-specific. There is no single API that works across FPM, FrankenPHP, and Swoole.
Abstract the logic behind an interface and test under each runtime you deploy.
Binding scope also matters. The difference between singleton and scoped determines whether the system behaves correctly or corrupts subsequent requests.
Reverse proxies introduce another buffering layer. Your PHP code can flush perfectly and events may still batch if nginx or a load balancer buffers responses.
SSE under Octane is not fundamentally harder than under FPM. It simply requires awareness of the persistent worker model.
Your process outlives the request โ design your streaming services accordingly.