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.

SSR Streaming

Send HTML to the browser progressively as coroutines resolve — like React's renderToPipeableStream, but in PHP. Three APIs, same result: the browser paints content incrementally.

Returning a \Generator from any handler triggers streaming — see the universal return contract for the full table of return shapes, and the file-execution family for how App::renderStream() composes with render() / renderToString() / include() / fragment().

📤

Generator yield

Return a \Generator from your handler. Each yield $html flushes immediately. No API changes needed.

🔁

stream() callback

Get a $write(string) closure. Headers flush before callback runs. Fine-grained control.

📡

sse()

Server-Sent Events. Get an $emit($data, $event, $id) closure. JS EventSource compatible.

Generator SSR — parallel coroutine fetches
$app->route('/stream/ssr', function() {
    $start = microtime(true);
    return (function() use ($start) {
        // 1. Shell sent to browser immediately
        yield '<html><body><h1>Page</h1>';

        // 2. Parallel fetch via coroutines
        $ch = new Channel(2);
        go(fn() => [$ch->push(fetchUsers()), co::sleep(1)]);
        go(fn() => [$ch->push(fetchPosts()), co::sleep(2)]);

        // 3. Stream each section as it resolves
        yield '<div id="users">' . $ch->pop() . '</div>';  // arrives at ~1s
        yield '<div id="posts">' . $ch->pop() . '</div>';  // arrives at ~2s
        yield '</body></html>';
    })();
    // Total: ~2s (parallel), not 3s (sequential)
});

Live streaming demos

GET/stream/ssr — Generator yield

Opens a streaming connection. Watch sections appear one by one (1s, then 2s):

Click Run to start…
SSE/stream/events — Server-Sent Events

10 events, 1 second apart. EventSource reconnects automatically on drop.

Click Connect to start…
SSE — $response->sse()
$app->route('/stream/events', function($response) {
    $response->sse(function($emit) {
        $emit(json_encode(['status' => 'connected']), 'open');
        for ($i = 1; $i <= 10; $i++) {
            co::sleep(1);
            $emit(json_encode(['tick' => $i, 'time' => date('H:i:s')]), 'tick', (string)$i);
        }
        $emit(json_encode(['done' => true]), 'done');
    });
});

// Browser:
// const es = new EventSource('/stream/events');
// es.addEventListener('tick', e => console.log(JSON.parse(e.data)));

Streaming from templates — App::renderStream()

Templates can yield. Return a Closure with named parameters — the framework injects them by name (same as route handlers). Each yield flushes to the browser immediately. Regular echo-based templates work too — output captured as one chunk.

Streaming template — template/dashboard/stats.php
<?php
// Declare what data you need — framework injects by name
return function($metrics) {
    yield "<div class='stats-grid'>";
    foreach ($metrics as $label => $value) {
        yield "<div class='stat'>"
            . "<span class='stat-value'>{$value}</span>"
            . "<span class='stat-label'>{$label}</span>"
            . "</div>";
    }
    yield "</div>";
};
Compose multiple streaming + regular templates in one route
$app->route('/dashboard', function() {
    return (function() {
        // Regular template → yields as single chunk
        yield from App::renderStream('shell-open', ['title' => 'Dashboard']);

        // Streaming template → yields per-metric card
        yield from App::renderStream('dashboard/stats', [
            'metrics' => ['Requests' => '67k/s', 'Latency' => '21ms', 'Workers' => 4],
        ]);

        // Another streaming template — yields per-row
        yield from App::renderStream('dashboard/activity', [
            'events' => fetchRecentEvents(),
        ]);

        // Regular template → yields as single chunk
        yield from App::renderStream('shell-close');
    })();
});

Three template styles — all compose in the same pipeline

Template styleCodeWhat renderStream() does
Closure (cleanest) return function($users) { yield ...; }; Injects params by name via Reflection, calls closure, yield from Generator
IIFE Generator return (function() use ($v) { yield ...; })(); Template returns Generator directly, yield from it
Regular echo <h1><?= $title ?></h1> Captures output, yields as one chunk
Advanced: parallel coroutine fetches + template streaming
use OpenSwoole\Coroutine\Channel;

$app->route('/feed', function() {
    return (function() {
        yield from App::renderStream('shell-open', ['title' => 'Feed']);

        // Fetch data in parallel — stream results as each completes
        $ch = new Channel(3);
        go(fn() => $ch->push(['type' => 'users',    'data' => fetchUsers()]));
        go(fn() => $ch->push(['type' => 'posts',    'data' => fetchPosts()]));
        go(fn() => $ch->push(['type' => 'comments', 'data' => fetchComments()]));

        for ($i = 0; $i < 3; $i++) {
            $result = $ch->pop();
            // Each section streams as its data arrives
            yield from App::renderStream(
                "feed/{$result['type']}",
                ['items' => $result['data']]
            );
        }

        yield from App::renderStream('shell-close');
    })();
});
Why PHP for streaming? Unlike Blade/Twig which require a compile step and can't yield, ZealPHP templates are raw PHP — yield is a native language feature. Templates declare their dependencies as function parameters, the framework injects them, and each yield flushes to the client. No special template syntax, no streaming adapter — just PHP generators.
Range requests and streaming. Streaming responses (Generator, stream(), sse()) automatically send Accept-Ranges: none — the total size is unknown, so byte-range serving doesn't apply. Buffered responses get full Range support via RangeMiddleware.