Streaming report
This report streams text, escaped native list items, and JSON progress from one declared action, then retains the completed report for its final render.
Complete installation first. The example uses no database, external services, or additional browser libraries. The demo uses TestApp; change the namespace to App in your application. Run the demo at /streaming.
Files
Create the island, template, and element at the following application paths:
src/Island/StreamingReportIsland.phptemplates/island/streaming_report.phptemplates/element/streaming_row.phptemplates/element/streaming_start.php
<?php
declare(strict_types=1);
namespace TestApp\Island;
use Interact\Attribute\Action;
use Interact\Attribute\State;
use Interact\Island;
/**
* Streams a report as text and escaped native elements while keeping the final report in state.
*
* The sample deliberately paces its results to make incremental delivery visible.
* Real applications emit results as their own work produces them.
*/
class StreamingReportIsland extends Island
{
#[State]
public string $report = '';
/**
* @var list<array{title: string, description: string}> Completed application results.
*/
#[State]
public array $sections = [];
/**
* Emit each sample result and retain it for the final authoritative render.
*/
#[Action(stream: true)]
public function generate(): void
{
$this->report = '';
$this->sections = [];
$this->stream('report', '', replace: true);
$this->streamElement('rows', 'streaming_start', replace: true);
$this->streamEvent('progress', ['percent' => 0]);
$sections = [
['title' => 'CakePHP templates', 'description' => 'Rendered on the server. Delivered as it happens.'],
['title' => 'Escaped <application> values', 'description' => 'Real data, safely displayed as text.'],
['title' => 'Signed final state', 'description' => 'The completed report survives the final render.'],
];
foreach ($sections as $index => $section) {
// Demo pacing only: application work should stream results when they become available.
usleep(600_000);
$content = sprintf("%02d %s\n %s\n\n", $index + 1, $section['title'], $section['description']);
$this->report .= $content;
$this->sections[] = $section;
$this->stream('report', $content);
$this->streamElement('rows', 'streaming_row', ['index' => $index + 1, ...$section], replace: $index === 0);
$this->streamEvent('progress', ['percent' => (int)round((($index + 1) / 3) * 100)]);
}
}
}<?php
declare(strict_types=1);
use function Cake\Core\h;
/**
* @var \Interact\View\TemplateView $this
* @var string $report
* @var list<array{title: string, description: string}> $sections
*/
?>
<div
class="stream-workspace"
x-data="{ percent: 0 }"
@interact:stream.window="
$event.target === $el.closest('[data-interact-id]') &&
$event.detail.name === 'progress' &&
(percent = $event.detail.detail.percent)
"
>
<div class="stream-toolbar">
<div class="stream-title">
<span class="stream-mark" aria-hidden="true">↗</span>
<div>
<h2>Report runner</h2>
<p>Three results. One continuous response.</p>
</div>
</div>
<button
type="button"
class="button-primary stream-run"
@click="$server.call('generate')"
:disabled="$server.busy"
>
<span class="stream-run-icon" :class="{ 'is-running': $server.busy }" aria-hidden="true">↻</span>
Generate report
</button>
</div>
<div class="stream-progress-heading">
<span class="stream-state" :class="{ 'is-live': $server.busy, 'is-complete': percent === 100 }" role="status">
<span class="stream-state-dot" aria-hidden="true"></span>
<span x-text="$server.busy ? 'Streaming live' : (percent === 100 ? 'Report complete' : 'Ready to run')">Ready to run</span>
</span>
<span class="stream-percentage"><span x-text="percent">0</span>%</span>
</div>
<progress class="stream-meter" max="100" value="0" :value="percent" aria-label="Report progress"></progress>
<div class="stream-panels">
<section class="stream-console" aria-label="Live text output">
<div class="stream-panel-heading">
<span>Text stream</span>
<span class="stream-format">PLAIN TEXT</span>
</div>
<div class="stream-console-body" :class="{ 'is-receiving': $server.busy }">
<div class="stream-empty" x-show="percent === 0 && !$server.busy">
<span class="stream-prompt" aria-hidden="true">>_</span>
<p>Your next result starts here.</p>
<span>Generate a report to open the stream.</span>
</div>
<pre class="stream-transcript" x-interact:stream="report"><?= (string)h($report) ?></pre>
<span class="stream-cursor" x-show="$server.busy" x-cloak aria-hidden="true"></span>
</div>
<div class="stream-console-footer">
<span class="stream-connection-dot" :class="{ 'is-connected': $server.busy }" aria-hidden="true"></span>
<span x-text="$server.busy ? 'Connection open · receiving results' : (percent === 100 ? 'Stream closed · all results received' : 'Waiting for a request')">Waiting for a request</span>
</div>
</section>
<section class="stream-results" aria-label="Rendered results">
<div class="stream-panel-heading">
<span>Rendered elements</span>
<span class="stream-format">HTML</span>
</div>
<ul class="stream-result-list" x-interact:stream.html="rows">
<?php if ($sections === []): ?>
<li class="stream-results-empty">
<span class="stream-empty-stack" aria-hidden="true"><i></i><i></i><i></i></span>
<p>Results, as they arrive.</p>
<span>Each card is a native CakePHP element.</span>
</li>
<?php else: ?>
<?php foreach ($sections as $index => $section): ?>
<?= $this->element('streaming_row', ['index' => $index + 1, ...$section]) ?>
<?php endforeach; ?>
<?php endif; ?>
</ul>
</section>
</div>
<div class="stream-footer">
<span class="stream-footer-symbol" aria-hidden="true">✳</span>
<p x-text="$server.busy ? 'No refresh. No waiting for the whole report.' : (percent === 100 ? 'All done. The final render keeps every result.' : 'Server-rendered, one result at a time.')">Server-rendered, one result at a time.</p>
<span class="stream-footer-tag">Text + HTML + progress</span>
</div>
<p class="request-error stream-request-error" role="alert" x-show="$server.error" x-text="$server.error ? $server.error.message : ''" x-cloak></p>
</div><?php
declare(strict_types=1);
use function Cake\Core\h;
/**
* @var int $index
* @var string $title
* @var string $description
*/
?>
<li class="stream-result">
<span class="stream-result-number" aria-hidden="true"><?= sprintf('%02d', $index) ?></span>
<div>
<h3><?= (string)h($title) ?></h3>
<p><?= (string)h($description) ?></p>
</div>
<span class="stream-result-check" aria-label="Received">✓</span>
</li><?php
declare(strict_types=1);
?>
<li class="stream-results-empty is-waiting">
<span class="stream-waiting-bars" aria-hidden="true"><i></i><i></i><i></i></span>
<p>Building your report…</p>
<span>The first result is on its way.</span>
</li><?= $this->Interact->render('StreamingReport') ?>Choose Generate report. Each result goes to the text target and HTML list; progress events update the Alpine indicator. The final template uses $report to retain the completed output. The demo intentionally waits 600 ms between results so incremental delivery is visible. Replace that sample pacing with your application work and emit each result as it becomes available. The panel styles live in the demo demo.css; the streaming API does not require those styles.
See streaming for lifecycle, deployment, errors, renderless completion, and the custom adapter receiving contract.