Low-Level PHP: Building Server-Sent Events from Scratch
PHP can do more than serve HTML. Exploring output buffer control, TCP connection detection, and memory management by building a production SSE streaming service.
Building an SSE Streaming Service in Pure PHP
PHP often gets dismissed as a high-level scripting language. But underneath the frameworks and ORMs, PHP exposes low-level primitives for controlling output buffers, detecting TCP connection state, managing memory, and writing directly to sockets.
I recently built a production SSE (Server-Sent Events) streaming service for a multi-step order processing pipeline, and it required reaching deep into PHP's runtime internals.
This post covers the core SSE implementation in pure PHP.
A follow-up post covers the additional challenges of running SSE under Laravel Octane with persistent workers.
What We're Building
When a user submits an order, multiple things happen server-side:
- input validation
- inventory checks
- payment processing
- fulfillment
The flow looks like this:
Client: POST /api/orders/initiate
Server:
202 { token: "uuid", stream_url: "/api/orders/stream/uuid" }
Client:
GET /api/orders/stream/uuid
Server:
event: status
data: {"stage":"validation","message":"Validating order details"}
Server:
event: status
data: {"stage":"payment","message":"Processing payment"}
Server:
event: complete
data: {"stage":"complete","order_id":"ORD-2026-00451"}The connection stays open. The server pushes events as they happen.
No WebSockets.
No polling.
No external dependencies.
This pattern works for any multi-step backend process:
- document generation
- report compilation
- file processing pipelines
- CI/CD status feeds
The Output Buffer Problem
PHP buffers output by default.
When you echo something, it does not immediately go to the client. It sits in an internal buffer until:
- the script ends, or
- the buffer fills up
PHP actually has multiple layers of buffering:
ob_* functions) We need to bypass all of them.
The Buffering Manager
final class BufferingManager
{
public function disable(): array
{
// Snapshot current settings
$originalSettings = [
'output_buffering' => ini_get('output_buffering'),
'zlib_compression' => ini_get('zlib.output_compression'),
'ob_level' => ob_get_level(),
];
// Destroy active output buffers
if (ob_get_level() > 0) {
ob_end_clean();
}
// Disable buffering
@ini_set('output_buffering', 'Off');
@ini_set('zlib.output_compression', '0');
// Disable Apache gzip
if (function_exists('apache_setenv')) {
apache_setenv('no-gzip', '1');
}
return $originalSettings;
}
public function restore(array $settings): void
{
if ($settings['output_buffering'] !== false) {
@ini_set('output_buffering', $settings['output_buffering']);
}
if ($settings['zlib_compression'] !== false) {
@ini_set(
'zlib.output_compression',
$settings['zlib_compression']
);
}
if ($settings['ob_level'] > 0 && ob_get_level() === 0) {
ob_start();
}
}
}What this does
ob_get_level()returns the nesting depth of output buffersob_end_clean()destroys the active buffer without sending dataini_set('output_buffering','Off')disables PHP output bufferingini_set('zlib.output_compression','0')disables compressionapache_setenv('no-gzip','1')disables Apache gzip
@ suppression on ini_set is intentional. Some SAPIs make these values read-only.
Flushing: The Dual-Layer Push
Even with buffering disabled, echo alone does not guarantee that data reaches the client.
We need explicit flushing.
private function flushOutput(): void
{
if (ob_get_level() > 0) {
ob_flush();
}
flush();
}These functions operate on different layers.
ob_flush()
Moves data from the userspace output buffer to PHP's internal output handler.
flush()
Pushes data from PHP's internal output handler to the SAPI/network layer.
Both calls are required.
Detecting Dead Connections
SSE connections can die at any time:
- browser closes
- network drops
- proxy timeout
private function isDisconnected(): bool
{
return connection_aborted() !== 0;
}connection_aborted() checks whether the client has disconnected.
However, the state only updates after a write attempt.
If the script is sleeping and not writing anything, PHP won't know the client is gone.
That is why the stream periodically writes events โ every write also acts as a connection health check.
Writing SSE Messages
The SSE protocol is plain text defined by the HTML specification.
private function sendEvent(string $event, array $data): void
{
echo "event: {$event}\n";
echo 'data: ' . json_encode($data, JSON_THROW_ON_ERROR) . "\n";
echo 'retry: ' . self::SSE_RETRY_MS . "\n";
echo "\n";
$this->flushOutput();
}Each message contains fields followed by a blank line.
event Defines the event type. data Contains the payload. retry Defines browser reconnection delay.The double newline ends the message.
Without it, the browser will continue buffering indefinitely.
The Stream Loop
The streaming loop ties everything together.
final readonly class SseStreamService implements SseStreamInterface
{
private const int POLL_INTERVAL_US = 500_000;
private const int MAX_DURATION_S = 120;
private const int GC_INTERVAL = 20;
private const int SSE_RETRY_MS = 1_000;
public function createStream(
callable $statusProvider,
callable $formatter
): StreamedResponse {
return new StreamedResponse(
fn () => $this->stream($statusProvider, $formatter),
200,
[
'Content-Type' => 'text/event-stream',
'Cache-Control' => 'no-cache',
'Connection' => 'keep-alive',
'X-Accel-Buffering' => 'no',
]
);
}
private function stream(
callable $statusProvider,
callable $formatter
): void {
$originalSettings = $this->bufferingManager->disable();
try {
$this->executeLoop($statusProvider, $formatter);
} finally {
$this->bufferingManager->restore($originalSettings);
gc_collect_cycles();
}
}
}The Execution Loop
private function executeLoop(
callable $statusProvider,
callable $formatter
): void {
$startTime = time();
$lastStage = '';
$iteration = 0;
while (true) {
$iteration++;
if ($this->isDisconnected()) {
break;
}
$status = $statusProvider();
if ($status === null) {
$this->sendEvent('error', [
'error_code' => 'SESSION_EXPIRED',
'message' => 'Processing session expired',
]);
break;
}
$stage = $this->resolveStage($status);
if ($stage !== $lastStage) {
$lastStage = $stage;
if ($this->isTerminal($status)) {
$this->sendEvent(
$this->terminalEventName($stage),
$formatter($status)
);
break;
}
$this->sendEvent('status', $formatter($status));
}
if ((time() - $startTime) >= self::MAX_DURATION_S) {
$this->sendEvent('error', [
'error_code' => 'TIMEOUT',
'message' => 'Processing timed out.',
]);
break;
}
if ($iteration % self::GC_INTERVAL === 0) {
gc_collect_cycles();
}
usleep(self::POLL_INTERVAL_US);
}
}Key points:
usleep(500_000)suspends the process for 500msgc_collect_cycles()prevents circular reference memory leaks- stage deduplication avoids sending duplicate events
Polymorphic Status Resolution
The status object can be:
- a typed DTO
- a cached array
- a legacy object
private function resolveStage(mixed $status): string
{
if ($status instanceof StreamableStatusInterface) {
return $status->getStage();
}
if (is_array($status) && isset($status['stage'])) {
return (string) $status['stage'];
}
if (is_object($status) && property_exists($status, 'stage')) {
return (string) $status->stage;
}
return '';
}Three resolution tiers optimize the common case first.
What PHP Is Actually Doing
Under the hood:
Transfer-Encoding: chunkedecho + flush() triggers a write() syscallusleep() calls nanosleep()connection_aborted() checks connection stategc_collect_cycles() frees circular referencesPHP is not just formatting strings.
It is managing TCP sockets, kernel buffers, and memory graphs.
What's Next
This implementation works under PHP-FPM.
However, running SSE under Laravel Octane introduces additional challenges:
- persistent workers
- runtime-specific connection detection
- buffer state leaking between requests
- correct singleton vs scoped bindings