Server-Sent Events (SSE)
OxPHP streams real-time data to clients over the Server-Sent Events protocol, with built-in backpressure. Set Content-Type: text/event-stream in your PHP script and call oxphp_stream_flush(). OxPHP handles the rest.
How it works
A streaming response goes through these stages:
- Your PHP script sets
Content-Type: text/event-streamviaheader()and writes SSE-formatted lines usingecho. - The first call to
oxphp_stream_flush()sends the HTTP headers to the client and enters streaming mode. The client connection remains open. - Each subsequent call to
oxphp_stream_flush()flushes buffered output to the client immediately, as a new chunk. - OxPHP keeps an internal buffer of up to 64 chunks between the PHP worker and the client. When the buffer is full because a slow client has not consumed earlier chunks,
oxphp_stream_flush()blocks until space becomes available. This backpressure prevents unbounded memory growth. - When the PHP script finishes, OxPHP closes the connection gracefully. If the client disconnects mid-stream, the script finds out at the first flush after OxPHP sees the connection close. OxPHP sets PHP's
connection_aborted()flag and, unless the script has calledignore_user_abort(true), stops the script inside that flush: the call does not return, which is how PHP stops a script under PHP-FPM when its output can no longer be written. A script that has calledignore_user_abort(true)gets the flush back and keeps running, and checkingconnection_aborted()is what ends its loop (see Detecting client disconnects).
Keep event payloads small. Large payloads can fill the 64-chunk buffer quickly, and PHP then blocks on each flush, which makes throughput uneven.
Examples
Basic SSE stream
<?php
header('Content-Type: text/event-stream');
header('Cache-Control: no-cache');
header('Connection: keep-alive');
for ($i = 0; $i < 100; $i++) {
$data = json_encode(['counter' => $i, 'time' => microtime(true)]);
echo "id: {$i}\n";
echo "event: tick\n";
echo "data: {$data}\n\n";
oxphp_stream_flush();
sleep(1);
// Send a comment heartbeat every 15 seconds to keep proxies from closing idle connections
if ($i % 15 === 0) {
echo ": heartbeat\n\n";
oxphp_stream_flush();
}
}Checking streaming state
Use oxphp_is_streaming() to check whether the current request is already in streaming mode. This is useful in middleware or shared request handlers:
<?php
if (!oxphp_is_streaming()) {
header('Content-Type: text/event-stream');
header('Cache-Control: no-cache');
}
echo "data: {\"status\": \"connected\"}\n\n";
oxphp_stream_flush();Detecting client disconnects
A stream that has sent its headers learns that its client has gone only when it flushes. By default that flush is where the script stops: oxphp_stream_flush() (or flush()) does not return, so a while (!connection_aborted()) condition is never evaluated again, and every finally block the flush was inside is skipped. PHP-FPM stops a script whose output can no longer be written the same way.
What still runs is the end of the request: register_shutdown_function() callbacks, in which connection_aborted() returns 1, and the destructors of the objects that go away with the request. Unlike PHP-FPM, which discards whatever those write, OxPHP stops a callback at the first output it sends and skips the callbacks registered after it. The destructors still run, and the first of them to send output is stopped the same way, with the destructors due after it skipped. Output held in an output buffer (one opened with ob_start(), or the one output_buffering in php.ini opens) is sent only when that buffer is flushed. Keep that cleanup free of output, or call ignore_user_abort(true) as below.
To clean up in the script itself, in a finally block or after the loop, call ignore_user_abort(true) and let connection_aborted() end the loop. The flush that finds the client gone then returns, connection_aborted() returns 1 from that point on, and the loop leaves through its condition:
<?php
header('Content-Type: text/event-stream');
header('Cache-Control: no-cache');
// A stream outlives max_execution_time, which is 30 s unless the PHP configuration sets another value
set_time_limit(0);
// Keep running when the client leaves, so that the loop below sees it and ends
ignore_user_abort(true);
$db = new PDO(/* ... */);
try {
while (!connection_aborted()) {
echo "data: " . json_encode(['ts' => time()]) . "\n\n";
oxphp_stream_flush();
sleep(1);
}
} finally {
$db = null; // also runs when the loop ends on a disconnect
}With the call, the connection_aborted() check is the only thing that ends the loop: the script keeps running after the client disconnects, what it writes is discarded, and connection_aborted() reports the disconnect. This is the same contract as under any other SAPI. Such a loop needs a bound of its own: a stream that has asked to outlive its client, and neither checks connection_aborted() nor ends by itself, holds its worker until the server shuts down.
The call applies only to the request that makes it. The value a request starts with counts the same way, though: ignore_user_abort = On in php.ini, or an ignore_user_abort(true) in a worker script's bootstrap (the code before its request loop, which some worker examples begin with), is the baseline every request starts from, so every stream served there behaves as if it had made the call and needs its connection_aborted() check.
Using native flush()
PHP's native flush() also works for streaming, but requires clearing all output buffer layers first. Prefer oxphp_stream_flush(), which manages output buffers automatically and integrates with OxPHP's backpressure system.
<?php
header('Content-Type: text/event-stream');
header('Cache-Control: no-cache');
while (ob_get_level()) {
ob_end_clean();
}
for ($i = 0; $i < 100; $i++) {
echo "data: " . json_encode(['counter' => $i]) . "\n\n";
flush();
sleep(1);
}SSE with worker mode
SSE works in both standard and worker mode. In worker mode, a stream holds its worker thread only while its script runs or waits without suspending its fiber, as a flush does while the 64-chunk buffer is full. While the stream waits in oxphp_sleep() or on a read through the streams runtime hook, the thread serves other requests; a worker thread runs up to 256 requests at once (see Fiber Multiplexing).
In a broadcast every open stream has to receive every event, so the example reads them from a Redis Stream rather than popping them off a list. Reading a stream leaves its entries where they are and each reader keeps its own position, so every client reads every event the stream still holds. BRPOP on a list works the other way: it removes the element it returns, and that element goes to exactly one of the connections waiting on the list. That is the right shape for a work queue and the wrong one for a broadcast: with two streams open, each event would reach only one of them.
<?php
require __DIR__ . '/../vendor/autoload.php';
oxphp_worker(function () {
if (($_SERVER['HTTP_ACCEPT'] ?? '') !== 'text/event-stream') {
http_response_code(400);
echo json_encode(['error' => 'SSE only']);
return;
}
header('Content-Type: text/event-stream');
header('Cache-Control: no-cache');
// A stream outlives max_execution_time, which is 30 s unless the PHP configuration sets another value
set_time_limit(0);
// One connection per stream: the blocking read below holds it for as long as the
// stream lives. The last argument is the read timeout, which has to exceed the
// 25 s the read blocks for.
$redis = new Redis();
$redis->connect('redis', 6379, 2.0, null, 0, 30.0);
// The newest entry in the stream. It also finds out whether the key holds a stream at all,
// while the answer can still be an error status: once the first frame is out the response is
// a 200, and a browser reconnects after every close of one.
$tail = $redis->xRevRange('events', '+', '-', 1);
if ($tail === false) {
error_log('SSE: ' . $redis->getLastError());
http_response_code(500);
return;
}
// Resume after the last event the browser saw (it sends the id back in Last-Event-ID).
// A first connection, or a header that is not a stream id, starts after the newest entry.
$last = $_SERVER['HTTP_LAST_EVENT_ID'] ?? '';
if (!preg_match('/^\d{1,19}-\d{1,19}$/', $last)) {
$last = $tail ? array_key_first($tail) : '0-0';
}
// Send the headers now; a quiet stream would otherwise send none until its first read returns.
// This named event carries the starting id, which the browser takes as its Last-Event-ID before
// the first event arrives; onmessage does not fire for it.
echo "event: ready\nid: {$last}\ndata:\n\n";
oxphp_stream_flush();
while (true) {
// Reading leaves the entries in the stream, so every open stream gets every one
$read = $redis->xRead(['events' => $last], 100, 25000);
if ($read === false) {
// Redis refused the read, the key having been replaced by a non-stream, say. End the stream;
// the browser reconnects and the check above answers it with an error status
return;
}
foreach ($read['events'] ?? [] as $id => $fields) {
echo "id: {$id}\ndata: {$fields['data']}\n\n";
$last = $id;
}
if (!$read) {
// No entry within 25 s — send heartbeat to keep the connection alive
echo ": heartbeat\n\n";
}
oxphp_stream_flush();
}
});Publish from any PHP process (a request handler, a queue worker, a CLI script):
$redis->xAdd('events', '*', ['data' => json_encode($payload)], 1000, true);The last two arguments cap the stream at roughly 1000 entries (MAXLEN ~ 1000). Send JSON, or anything else without a line break, as data: the example writes it on a single data: line.
What the example relies on:
- The entry id is the event id. An id like
1790976033892-0goes out as the event'sid:, the browser returns the last one it saw inLast-Event-IDwhen it reconnects, and the stream continues after it (see Behaviour on shutdown). - The first frame is an event that carries the starting id. Safari and Firefox take an
id:as the browser'sLast-Event-IDonly from a frame that has adata:line, so a bareid:would leave a browser cut off before its first event with no id to send: it would reconnect, start from the newest entry and miss whatever was published in between. Thereadyevent, with an emptydata:line, gives it one.onmessagedoes not fire for a named event andaddEventListener('ready', …)does; a client library that hands every event to one callback has to skip it. - A stream that cannot start gets an error status, before its first frame. Redis refuses to read a key that holds something other than a stream (a list left over from an earlier queue, say), and no id would make that read work, so the example finds it out with its first command, logs the message Redis gives and answers
500. It has to be a status rather than a stream that just ends: once the first frame is out the response is a200, and a browser reconnects after every close of a200stream, every few seconds for as long as the page stays open, each time with a new Redis connection and nothing in the log. AnEventSourcedoes not retry after an error status; the page gets anerrorevent withreadyStateatEventSource.CLOSEDand has to open a newEventSourceitself if it wants one. The same goes for a Redis that cannot be reached:connect()throws, the response is a500, and theEventSourceis closed for good. A read that Redis refuses after the stream has started (the key replaced by a non-stream, say) ends it with areturn, and the reconnect goes through the check above. Without thatreturnthe loop would take thefalsefor a timeout and send nothing but heartbeats, back to back. Last-Event-IDis checked for its format. The header comes from the client and can hold anything, and Redis refuses a value that is not a stream id,abcfor instance. The example treats such a value like a missing header and starts after the newest entry, the way a first connection does. The pattern admits up to 19 digits on each side of the dash, which keeps every value it accepts inside the range Redis reads.- A new stream reads the newest id once. It does not pass
$on every call: Redis resolves$to the newest id at the moment of each call, so entries added between two calls are skipped. - History is bounded. A reader whose last event has since been trimmed away (a browser that was disconnected, or a stream held up by a slow client while more than the cap was published) continues from the oldest entry still there. The entries trimmed in between are not delivered, and nothing reports it. Size the cap to cover the longest outage you want a stream to resume from.
- Each stream has its own connection, with a read timeout above the block time. Without the timeout argument phpredis falls back to
default_socket_timeout, and a read that blocks for longer than that fails with aRedisException. Do not open one connection per worker and hand it to every stream: with thestreamsruntime hooks on, a worker runs other requests while a stream waits on a read, and a request that has used a connection keeps it until it ends. A second stream that reaches the same connection waits for the first. If the first is still open when the wait runs out (the bound comes frommax_execution_timeanddefault_socket_timeout; see the hooks section), phpredis throws aRedisExceptionand the second stream fails. - The script switches off PHP's execution timer.
max_execution_timeis 30 seconds unless the PHP configuration sets another value, and the release image ships nophp.ini; a stream is meant to outlive it. With the timer left on, the script ends with a fatal error: the timer fires after 30 seconds and takes effect when the read in progress returns, so with the 25-second reads here a quiet stream ends at the 50th second. In worker mode the call switches off the limit of the stream's own request and no other; see Worker Mode → Request deadlines. - A client that has closed its connection is noticed at the next flush. Until then its request, and the Redis connection with it, stays open: at most 25 s here, the block time, which is also the heartbeat interval.
Redis Pub/Sub also delivers each message to every connected subscriber, but it keeps nothing: a message published while a client is reconnecting is gone, and there is no id to resume from. Use it only where a missed event does not matter.
Several instances behind a load balancer
A stream is one long connection to one instance, chosen once by the balancer. With more than one instance:
- Events have to cross instances.
OxPHP\Shared\*objects live inside one process (see Shared State), so a stream on one instance cannot see an event published on another through them. Both have to read the same external store, which is what the Redis Stream above is. - Capacity is counted per instance. Each instance serves the streams its own worker pool leaves room for (see Best practices). Prefer a balancing method that counts open connections, such as
least_connin nginx, over round-robin: round-robin balances connections by arrival, not by how long they stay open, and a stream stays open far longer than an ordinary request. - Sticky sessions are not needed. A reconnecting browser may land on a different instance, which is fine as long as the first frame and every event carry an
id:and every instance reads the same stream. - The proxy must not buffer the stream. A buffering proxy holds events back until its buffer fills. In nginx, turn it off for the stream's location with
proxy_buffering off, or sendX-Accel-Buffering: nofrom the script, which nginx honours unlessproxy_ignore_headerssays otherwise. - Heartbeats must be more frequent than the balancer's idle timeout. See "Intermediate proxies close idle SSE connections" under Troubleshooting.
- Use HTTP/2 between the browser and the balancer. Over HTTP/1.1 a browser allows about six open connections per host across all its tabs, and each stream takes one; HTTP/2 carries many streams over a single connection.
Troubleshooting
The client receives no data until the script ends
The PHP output buffer is capturing output instead of streaming it. This happens when OB layers are active and oxphp_stream_flush() is not called.
Fix: Call oxphp_stream_flush() after each event. This function flushes all PHP output buffer layers and sends the accumulated output as a chunk.
SSE connections are closed after a few minutes
PHP's max_execution_time is firing and terminating the script. SSE streams must run longer than the configured limit.
Fix: Disable the per-request execution timer at the top of the streaming script:
set_time_limit(0);This is preferred over setting max_execution_time = 0 globally, because it leaves the limit in place for non-SSE endpoints. Alternatively, if the entire instance is dedicated to long-lived streams:
; php.ini
max_execution_time = 0Intermediate proxies close idle SSE connections
Load balancers and proxies often close connections that carry no data for 30 to 60 seconds.
Fix: Send a comment heartbeat at regular intervals to keep the connection active:
echo ": heartbeat\n\n";
oxphp_stream_flush();oxphp_stream_flush() returns false
oxphp_finish_request() was called earlier in the same request. Once the response is finished, streaming is not possible. Check your code for inadvertent calls to oxphp_finish_request() before streaming begins.
Docker example
SSE endpoints require PHP's execution timer to be disabled or set high. Outside worker mode, as in this example, each active SSE connection occupies one PHP worker for the full duration of the stream, so size the worker pool to accommodate your expected concurrent stream count.
services:
app:
image: ghcr.io/oxphp/oxphp:0.13.0
ports:
- "8080:80"
volumes:
- ./src:/var/www/html
environment:
DOCUMENT_ROOT: "/var/www/html/public"
ENTRY_FILE: "index.php"
PHP_WORKERS: "32"Each streaming script should call set_time_limit(0) at the top so the per-request timer does not fire mid-stream. This keeps the global max_execution_time in effect for non-SSE requests, in worker mode as well: there each request has a limit of its own, so a stream switching its own off leaves the requests served beside it bounded (see Worker Mode → Request deadlines).
Best practices
- Use
oxphp_stream_flush()instead of nativeflush()for automatic output buffer management and backpressure integration. - Send periodic comment heartbeats (
: heartbeat\n\n) every 20 to 30 seconds to keep intermediate proxies from closing idle connections and to detect client disconnections early. - Keep event payloads small. Large payloads fill the 64-chunk buffer faster, and PHP then stalls on each flush. For large data, send an event ID and let the client fetch the full payload via a separate request.
- Disable PHP's execution timer per-script with
set_time_limit(0)for long-lived SSE endpoints, or setmax_execution_timehigh enough to cover your longest expected stream duration. - Size your worker pool for peak concurrent streams. Outside worker mode, and in worker mode for a stream whose waits do not suspend its fiber, each active SSE connection holds one PHP worker for its full duration (see SSE with worker mode). In those cases, budget at least one worker per expected concurrent client, plus additional workers for regular non-SSE requests.
Notes
- Event streams are never compressed, by two independent rules:
text/event-streamis not a compressible media type, and a response whose length is unknown when the headers go out is passed through uncompressed whatever the client accepts. That holds for every coding OxPHP offers (Brotli, Zstandard and gzip alike), so no configuration can turn compression on for a stream. oxphp_stream_flush()returnsfalseifoxphp_finish_request()was already called on the same request.- In worker mode, a stream occupies its worker thread only while it runs or waits without suspending its fiber; while it waits in
oxphp_sleep()or on a hooked read, the thread handles other requests. In the other modes the worker stays occupied until the PHP script exits.
Behaviour on shutdown
When the server receives SIGTERM (a rolling deploy, a docker stop, a Kubernetes pod eviction), it drains gracefully:
- Each open SSE stream is ended cleanly on its next
flush. Its handler bails as if the connection closed, soregister_shutdown_function()callbacks still run anderror_get_last()['message']readsRequest cancelled (shutdown). - HTTP/2 clients receive a
GOAWAYframe; HTTP/1.1 keep-alive connections are closed. The browser'sEventSourcereconnects automatically (to a healthy instance when a load balancer fronts the fleet). - The stream does not receive a 503: its
200headers were already sent, so the status cannot be rewritten. Design clients to resume from the last event id (sendid:with each event; on reconnect the browser sends it back as theLast-Event-IDrequest header, available in PHP as$_SERVER['HTTP_LAST_EVENT_ID']). The worker-mode example does exactly that.
Ordinary (non-streaming) requests in flight at SIGTERM are not interrupted: they get the whole drain window to finish normally, and only requests still running when DRAIN_TIMEOUT_SECONDS (default 25) expires are cancelled, with ~2 more seconds to unwind. Set the orchestrator's termination grace period above DRAIN_TIMEOUT_SECONDS + 2, plus the longest blocking call a request can be in at the deadline (see Graceful Shutdown).
"Streaming" here means any response that has already flushed chunked output. The server cannot tell a finite streaming download from an infinite event stream, so a large flushed download in flight at SIGTERM is ended early as well, the same as an SSE stream. A request that called oxphp_finish_request() before SIGTERM counts as ordinary: its response is complete, and its remaining background work gets the drain window.
A handler blocked inside a single long-running native call (a native sleep(), a heavy preg_match, a blocking database query) cannot be interrupted until that call returns. In worker mode, prefer the cooperative oxphp_sleep() in streaming loops. It yields to the fiber scheduler and is woken immediately on shutdown. Outside worker mode oxphp_sleep() falls back to a regular blocking sleep, so the stream reacts to shutdown at its next flush after the sleep returns.
Prompts for your agent
These prompts assume an agent with the OxPHP documentation MCP server connected. The agent reads the pages it needs itself.
Build a Server-Sent Events endpoint in this project on OxPHP. Read the `features/sse` page through the OxPHP documentation MCP server.
Write the handler around the data this repository already has to push — heartbeats, client disconnects, resuming from the last event id — and tell me how many workers the concurrency I expect will need.See also
- Worker Mode: persistent PHP workers for reduced bootstrap overhead
- Timeouts: configuring or disabling the request timeout for long-lived connections
- PHP Functions: the full reference for
oxphp_stream_flush()andoxphp_is_streaming() - Compression: which responses are compressed, and with which coding