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

> Source: <https://dev.to/hammrouni/03-streaming-llm-tokens-in-php-with-server-sent-events-3k3a>
> Published: 2026-09-27 18:07:55+00:00

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.

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.

``` php
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."
    );
```

`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.

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

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;
}
```

Two things are non-negotiable here:

`ob_flush()` + `flush()` after every token.`X-Accel-Buffering: no`.
A plain `EventSource` - no library, no WebSocket:

``` js
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;
};
```

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.

`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](https://hammrouni.github.io/nanoagentphp/).*
