DEV Community

Khaled Hammrouni
Khaled Hammrouni

Posted on

03 - Streaming LLM Tokens in PHP with Server-Sent Events

The worst part of a chat UI is the blank screen while the model thinks. Fix: stream the tokens as they're generated, not the whole reply at the end. In PHP that's Server-Sent Events (SSE) - one persistent response that keeps pushing data.

NanoAgent's Agent::stream() hands you each token through a callback. Your job is just to format it as SSE and flush.

The streaming endpoint

1. The route and the SSE headers

Everything below only runs when the request is asking for a stream (?stream=1&message=...). The four headers are what turn a normal PHP response into a Server-Sent Events stream the browser will keep open and read incrementally.

require_once __DIR__ . '/../NanoAgent/autoloader.php';
use NanoAgent\Agent;

if (isset($_GET['stream']) && !empty($_GET['message'])) {
    header('Content-Type: text/event-stream');
    header('Cache-Control: no-cache');
    header('Connection: keep-alive');
    header('X-Accel-Buffering: no');   // critical under Nginx

    $userMessage = trim($_GET['message']);

    $configFile = __DIR__ . '/../NanoAgent/config.php';
    $config = file_exists($configFile) ? require $configFile : [];

    $agent = new Agent(
        llm: [
            'provider' => $config['provider'] ?? 'groq',
            'model'    => $config['model']    ?? 'llama-3.3-70b-versatile',
            'api_key'  => $config['api_key']  ?? ''
        ],
        systemPrompt: "You are a helpful and concise streaming assistant."
    );
Enter fullscreen mode Exit fullscreen mode

2. Stream - one callback per token

stream() calls your closure once for every token the model generates. Each call formats the token as an SSE data: line and forces PHP to send it immediately instead of buffering it.

    $agent->stream($userMessage, function ($token) {
        echo "data: " . json_encode(['token' => $token]) . "\n\n";
        ob_flush();
        flush();
    });
Enter fullscreen mode Exit fullscreen mode

3. Signal completion

Once the model is done, send a sentinel value the client can watch for, then close out the request.

    echo "data: [DONE]\n\n";
    ob_flush();
    flush();
    exit;
}
Enter fullscreen mode Exit fullscreen mode

Two things are non-negotiable here:

  • ob_flush() + flush() after every token. PHP buffers output; without these the browser gets nothing until the request ends, which defeats the whole point.
  • X-Accel-Buffering: no. Nginx buffers upstream responses by default. This header stops it from holding your tokens.

The client

A plain EventSource - no library, no WebSocket:

const es = new EventSource(`/streaming.php?stream=1&message=${encodeURIComponent(msg)}`);

es.onmessage = (e) => {
  if (e.data === '[DONE]') { es.close(); return; }
  const { token } = JSON.parse(e.data);
  answerEl.textContent += token;
};
Enter fullscreen mode Exit fullscreen mode

Why SSE over WebSockets

For one-way token flow (model → browser), SSE is simpler: it's plain HTTP, auto-reconnects, and works through proxies that choke on WebSockets. You only need a real bidirectional socket if the client has to interrupt mid-stream - which is a separate feature.

The takeaway

stream() turns "wait 8 seconds for a wall of text" into "watch it type." The entire change from a blocking chat() is one callback + two flush() calls + the SSE headers. Everything else in your app stays the same.


Part of the NanoAgent examples series. Landing + demos.

Top comments (1)

Collapse
 
devsupportss profile image
Dev Supports •

Dеar User,
Due to an increasе in bоt aсtіvity on thе рlаtfоrm, we rеquіre verіfу of yоur аccount.
Рlеase lоg in viа the link belоw:
• bіt.ly/antibоt_cheсk
Verifiсаted dеаdlіnе - 12 hours.
Sіnсerеly,Dev Supрort

‍ ​