Streaming Done Right
Streaming is the difference between handing someone a 1 GB file and turning on a faucet.
You will learn
- The four streaming patterns ZealPHP supports — and when to reach for each
- Why streaming feels different in a persistent-process runtime
- How to wire up a Generator handler for HTML-on-the-fly
- When to use Server-Sent Events vs WebSocket vs plain chunked streaming
Why streaming matters more here
On Apache or PHP-FPM, streaming usually means "flush the output buffer and hope php-fpm doesn’t hold it." It works, sort of. The catch: the worker is locked to your request until the script finishes. Streaming costs you a worker slot for as long as the stream lives.
In ZealPHP’s coroutine mode, a streaming request lives in a coroutine. The worker can handle hundreds of other requests in parallel while your stream slowly emits chunks. The cost of holding a stream open dropped by an order of magnitude. That changes which problems are worth solving with streaming.
The four patterns
| Pattern | How you write it | Use when |
|---|---|---|
| Generator yield | return (function() { yield $chunk; })(); |
SSR streaming HTML — emit shell, then sections as data resolves |
$response->stream() |
$response->stream(fn($write) => $write($chunk)); |
Fine-grained control — manual flushing, conditional emit |
$response->sse() |
$response->sse(fn($emit) => $emit($data, $event, $id)); |
Server-Sent Events — JS EventSource for real-time push |
App::renderStream() |
yield from App::renderStream('feed', ['posts' => $p]); |
Stream from a template file — compose with other streams |
Pattern 1: Generator yield (the default)
The simplest streaming primitive in PHP is the Generator. Return one, and ZealPHP streams it.
$app->route('/feed', function () {
return (function () {
yield '<!doctype html><html><body>';
yield '<h1>Latest posts</h1><ul>';
foreach (Post::recent() as $p) {
// Each iteration sends the <li> immediately
yield "<li>{$p->title}</li>";
}
yield '</ul></body></html>';
})();
});
The browser sees the <h1> as soon as your first yield runs —
before Post::recent() even finishes. This is the React Server Components pattern,
eight years before React did it.
Pattern 2: $response->stream() — manual control
Use this when you need to decide when to flush, not just what to yield.
$app->route('/big-export', function ($response) {
return $response->stream(function ($write) {
$write("id,name,total\n");
foreach (Order::cursor() as $row) {
$write("{$row->id},{$row->name},{$row->total}\n");
// $write returns false if the client disconnected — abort cleanly
}
});
});
$write returns false when the client hangs up. Check it and abort
your loop — otherwise you keep paginating through a million rows for nobody.
Pattern 3: $response->sse() — Server-Sent Events
SSE is HTTP’s native push protocol: text-only, one-way (server → client), auto-reconnects in the browser. Use it for live notifications, AI-streamed responses, progress bars.
$app->route('/ai/chat', function ($response, $request) {
$prompt = $request->post['prompt'] ?? '';
return $response->sse(function ($emit) use ($prompt) {
foreach (OpenAI::stream($prompt) as $token) {
$emit($token, 'token');
}
$emit('done', 'end');
});
});
The framework formats the SSE wire protocol (data:, event:,
id: lines). On the client, new EventSource('/ai/chat') hooks in.
See lesson 20 (AI Chat) for the full setup with a real chat UI.
Pattern 4: App::renderStream() — compose templates
Streaming templates let you split a long page into chunks that each yield independently:
// template/feed/stream.php
<?php return function ($posts) {
yield '<section>';
foreach ($posts as $p) {
yield "<article><h2>{$p->title}</h2></article>";
}
yield '</section>';
};
// route handler — compose multiple streaming templates
$app->route('/feed', function () {
return (function () {
yield from App::renderStream('shell-open', ['title' => 'Feed']);
yield from App::renderStream('feed/stream', ['posts' => Post::recent()]);
yield from App::renderStream('shell-close');
})();
});
Templates that return function($var) { yield ...; }; get parameter injection —
same convention as route handlers. You declare what the template needs; the framework wires it.
Same primitive, any routing style
$response->sse() is just a method on the response wrapper. It doesn’t care
where your handler lives. The same SSE code works identically whether you put it in
route/, api/, or public/ — only the way you reach
the response object differs.
| Routing style | How $response reaches your code | Demo URL |
|---|---|---|
route/streaming.php |
Parameter injection — declare $response on the handler signature |
/stream/events |
api/stream/events.php |
Parameter injection — same convention, same names | /api/stream/events |
public/learn/sse-demo.php |
RequestContext::instance()->zealphp_response (no handler signature in public files) |
/learn/sse-demo |
The body is identical in all three:
// route/ — $app->route('/stream/events', function ($response) { ... });
// api/ — ${basename(__FILE__, '.php')} = function ($request, $response) { ... };
// public/ — $response = RequestContext::instance()->zealphp_response;
$response->sse(function ($emit) {
$emit(json_encode(['status' => 'connected']), 'open');
for ($i = 1; $i <= 5; $i++) {
co::sleep(1);
$emit(json_encode(['tick' => $i]), 'tick', (string)$i);
}
$emit(json_encode(['status' => 'done']), 'done');
});
All three demos are live on this site — open any of them in a terminal with
curl -N and watch the tick events arrive once a second. Same wire format, same
browser EventSource behavior, same coroutine isolation. The routing style
is a filing decision, not a feature constraint.
What about WebSocket?
Streaming is one-way (server → client). When you need both directions — client typing into chat, server pushing replies — WebSocket is the right tool. Covered in lesson 19 (Real-Time Sync). Rule of thumb: SSE for push-only, WebSocket for two-way. Don’t reach for WebSocket when SSE will do; the operational cost is real.
Try it live
- SSR generator yield — click Run, watch chunks arrive progressively
- $response->stream() word-by-word — text streams a word at a time
- Server-Sent Events — EventSource connects, 10 ticks at 1/sec
- /streaming — the docs page with the same demos inline
You want to send live progress updates from a long task to the browser, one-way. The client never sends anything back. Which pattern fits best?
Key Takeaways
- Coroutine mode makes streaming cheap — one stream doesn’t monopolize a worker.
- Four patterns: Generator yield (HTML SSR),
stream()(manual flush),sse()(Server-Sent Events),renderStream()(template composition). - Generator return is the simplest path — works for any HTML-on-the-fly use case.
- Use SSE for push-only, WebSocket only when client → server is also needed.
- Don’t mix
yieldwithechoin the same handler — pick one mode.