Alpha ZealPHP is early-stage and under active development. APIs may change between minor versions until v1.0. Feedback and bug reports welcome on GitHub.

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

PatternHow you write itUse 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 styleHow $response reaches your codeDemo 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

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 yield with echo in the same handler — pick one mode.