spoova / iotext
A Text Streamer Package
Requires
- spoova/ghost: ^1.2
README
Live, single-request progress streaming for PHP.
Send one AJAX request, get real-time status updates back โ no polling, no background jobs, no extra endpoints. IOTextStream lets a long-running PHP process push lines like Processing data..., Extracting data..., Compressing file... straight to the client as they happen, over the same connection that started the request.
Part of the spoova/mi framework โ spoova\mi\core\classes\Bundle\IOText.
Why
Most "live progress" tutorials reach for polling: hit an endpoint every second, ask "are we done yet?". It works, but it's chatty, laggy, and needs somewhere to persist state between requests.
IOTextStream takes a simpler path: PHP output buffering plus chunked responses. The server echos and flush()es as work happens; the client reads the response body as a stream instead of waiting for it to finish. One request, real-time output, no extra moving parts.
Features
- ๐ด True single-request streaming โ no polling interval, no extra round trips
- ๐งญ Two modes โ stream directly from the process doing the work, or watch a file being written to by another process
- ๐ Built-in progress tracking โ
steps()+ tracked messages give you a live percentage for free - ๐งต Stacked, isolated contexts โ nested or sequential streaming blocks each get their own counters, with a fully static API
- ๐ชถ Zero dependencies beyond the framework's own
Ghost*proxy classes
Installation
IOTextStream ships as part of spoova/mi:
composer require spoova/mi
Quick start
Streaming directly from the request
Use this when the work โ extracting, compressing, whatever โ happens in the same PHP process that's handling the request.
use spoova\mi\core\classes\Bundle\IOText\IOTextStream; IOTextStream::init(steps: 4); IOTextStream::stream_view("Processing data..."); processData(); IOTextStream::stream_view("Extracting data..."); extractData(); IOTextStream::stream_view("Compressing file..."); compressFile(); IOTextStream::stream_view("Extraction complete..."); IOTextStream::close();
Each stream_view() call writes a line, flushes it to the client immediately, and counts toward IOTextStream::progress().
Reading it on the client
fetch() with a ReadableStream reader is what makes this work โ plain XMLHttpRequest can't read a response cleanly while it's still arriving.
async function watchProgress(url) { const res = await fetch(url); const reader = res.body.getReader(); const decoder = new TextDecoder(); let buffer = ''; while (true) { const { done, value } = await reader.read(); if (done) break; buffer += decoder.decode(value, { stream: true }); const lines = buffer.split('\n'); buffer = lines.pop(); for (const line of lines) { if (line.trim()) updateStatus(line); } } } function updateStatus(line) { document.getElementById('status').textContent = line; } watchProgress('/your-endpoint');
done becomes true once PHP's script ends and the connection closes โ not based on anything in the message content itself.
Watching a file instead
If the actual work happens in a separate process (a queued job, a subprocess, a CLI script), there's no shared PHP process to echo from directly. IOTextStream::stream_file() bridges that gap by watching a file for changes and relaying them into the response.
use spoova\mi\core\classes\Bundle\IOText\IOTextStream; use spoova\mi\core\classes\Bundle\IOText\IOTextFile; IOTextStream::init(); IOTextStream::stream_file('/path/to/progress.log', function (IOTextFile $file) { if ($file->isModified()) { IOTextStream::stream_view($file->textFromLine($file->lineCount())); } if (str_contains($file->text(), 'DONE')) { $file->stop(); } }, interval: 0.5); IOTextStream::close();
The callback receives a read-only IOTextFile snapshot with everything you need to react to changes:
| Method | Returns |
|---|---|
file() |
Path being watched |
text() |
Full file contents so far |
lines() |
File contents split into lines |
lineCount() |
Number of lines |
textFromLine(int $n) |
1-based line lookup |
isModified() |
false on the first read, true after |
modifiedAt() |
Last modification timestamp |
tick() |
Number of times a change has been detected |
exists() |
Whether the file currently exists |
stop() |
Ends the watch loop |
IOTextFile is abstract and only ever produced internally through a GhostProxy while stream_file() is running โ it isn't meant to be instantiated directly.
API reference
Output
| Method | Tracked? | Description |
|---|---|---|
view(string $message, int $timeout = 0) |
No | Writes and flushes a plain text line |
json(array $message, int $timeout = 0) |
No | Writes and flushes a JSON-encoded line |
stream_view(string $message, int $timeout = 0) |
Yes | Same as view(), counted toward progress |
stream_out(string $message, int $timeout = 0) |
Yes | Alias of stream_view() |
stream_json(array $message, int $timeout = 0) |
Yes | Same as json(), counted toward progress |
issue_text(string $message) |
No | Writes a line and immediately exits |
issue_json(array $message) |
No | Writes a JSON line and immediately exits |
Context & progress
| Method | Description |
|---|---|
init(int $steps = 0) |
Starts a new streaming context โ sets headers, opens the output buffer, pushes fresh counters |
close() |
Ends the current context and restores the previous one, if any |
steps(int $steps) |
Sets the total step count used to calculate progress |
interval(int $milliseconds) |
Sets a default delay applied after each message |
progress(bool $int = false) |
Percentage complete, based on tracked messages vs. total steps |
views() |
Total messages written in the current context |
streams() |
Total tracked messages written in the current context |
pause(int $seconds) |
Blocking delay, in seconds |
wait(int $milliseconds) |
Blocking delay, in milliseconds |
File watching
| Method | Description |
|---|---|
stream_file(string $path, Closure $callback, float $interval = 1.0) |
Watches $path for changes, invoking $callback(IOTextFile $file) on the first read and every change after, until $file->stop() is called |
Nested & sequential contexts
Every method above operates on a stack of independent contexts under the hood, so you can safely bracket multiple phases without one's counters bleeding into another's โ while every call stays a plain static method:
IOTextStream::init(steps: 2); IOTextStream::stream_view("Phase one, step one..."); IOTextStream::stream_view("Phase one, step two..."); IOTextStream::close(); IOTextStream::init(steps: 3); // completely fresh counters IOTextStream::stream_view("Phase two, step one..."); // ... IOTextStream::close();
Server configuration notes
Chunked flushing only reaches the browser if nothing between PHP and the client buffers it up first:
- Nginx โ
IOTextStream::init()already sendsX-Accel-Buffering: noto disable proxy buffering for the response. - Gzip /
zlib.output_compressionโ turn this off for streaming endpoints; compression buffers the whole payload before sending it. - Cloudflare or similar edge proxies โ may need additional configuration to pass chunks through as they arrive rather than buffering the full response.
Test streaming behavior early in your environment โ a working flush() locally doesn't guarantee the chunks survive every layer of infrastructure in front of it.
License
Part of the spoova/mi framework. See the main project repository for license details.