diff --git a/src/Http/README.md b/src/Http/README.md
index a2219e1..0c6fb7d 100644
--- a/src/Http/README.md
+++ b/src/Http/README.md
@@ -126,13 +126,23 @@ Other helpers:
```php
use Myxa\Support\Facades\Response;
+use Myxa\Http\StreamWriterInterface;
Response::text('Created', 201);
Response::html('
Hello
');
+Response::streaming(function (StreamWriterInterface $stream): void {
+ $stream->write("event: ping\n");
+ $stream->write('data: {"ok":true}' . "\n\n");
+}, 200, [
+ 'Content-Type' => 'text/event-stream',
+ 'X-Accel-Buffering' => 'no',
+]);
Response::redirect('/login');
Response::noContent();
```
+`Response::streaming()` is generic. It sends headers and cookies as usual, then runs the callback with a `StreamWriterInterface` so you can write chunks to the client without repeating `ob_flush()` and `flush()` by hand. It does not set a default `Content-Type` or `X-Accel-Buffering` header automatically, because those depend on what you are streaming, such as SSE, NDJSON, CSV, or plain text.
+
Headers and cookies can be chained onto the response:
```php
diff --git a/src/Http/Response.php b/src/Http/Response.php
index da35ed7..fc78c7a 100644
--- a/src/Http/Response.php
+++ b/src/Http/Response.php
@@ -20,6 +20,11 @@ final class Response
private string $content = '';
+ /**
+ * @var callable|null
+ */
+ private $streamCallback = null;
+
/**
* @var array
*/
@@ -78,6 +83,7 @@ public function statusCode(): int
*/
public function body(string $content): self
{
+ $this->streamCallback = null;
$this->content = $content;
return $this;
@@ -88,6 +94,7 @@ public function body(string $content): self
*/
public function append(string $content): self
{
+ $this->streamCallback = null;
$this->content .= $content;
return $this;
@@ -101,6 +108,34 @@ public function content(): string
return $this->content;
}
+ /**
+ * Configure the response to stream content when sent.
+ *
+ * @param callable(StreamWriterInterface): void $callback
+ * @param array $headers
+ */
+ public function streaming(callable $callback, int $statusCode = 200, array $headers = []): self
+ {
+ $this->status($statusCode);
+ $this->streamCallback = $callback;
+ $this->content = '';
+ $this->removeHeader('Content-Length');
+
+ foreach ($headers as $name => $value) {
+ $this->setHeader($name, (string) $value);
+ }
+
+ return $this;
+ }
+
+ /**
+ * Determine whether the response is configured for streaming output.
+ */
+ public function isStreaming(): bool
+ {
+ return is_callable($this->streamCallback);
+ }
+
/**
* Set or replace a response header value.
*/
@@ -315,6 +350,12 @@ public function send(): void
}
}
+ if ($this->streamCallback !== null) {
+ ($this->streamCallback)(new StreamWriter());
+
+ return;
+ }
+
echo $this->content;
}
diff --git a/src/Http/StreamWriter.php b/src/Http/StreamWriter.php
new file mode 100644
index 0000000..d04f293
--- /dev/null
+++ b/src/Http/StreamWriter.php
@@ -0,0 +1,30 @@
+flush();
+ }
+
+ /**
+ * Flush any buffered stream output to the client.
+ */
+ public function flush(): void
+ {
+ if (ob_get_level() > 0) {
+ ob_flush();
+ }
+
+ flush();
+ }
+}
diff --git a/src/Http/StreamWriterInterface.php b/src/Http/StreamWriterInterface.php
new file mode 100644
index 0000000..64aaba6
--- /dev/null
+++ b/src/Http/StreamWriterInterface.php
@@ -0,0 +1,18 @@
+content();
}
+ /**
+ * Configure the response to stream content when sent.
+ *
+ * @param callable(\Myxa\Http\StreamWriterInterface): void $callback
+ * @param array $headers
+ */
+ public static function streaming(callable $callback, int $statusCode = 200, array $headers = []): HttpResponse
+ {
+ return self::getResponse()->streaming($callback, $statusCode, $headers);
+ }
+
/**
* Set or replace a response header value.
*/
diff --git a/tests/Unit/Http/ResponseFacadeTest.php b/tests/Unit/Http/ResponseFacadeTest.php
index 268ab8b..cd14c15 100644
--- a/tests/Unit/Http/ResponseFacadeTest.php
+++ b/tests/Unit/Http/ResponseFacadeTest.php
@@ -7,6 +7,7 @@
use BadMethodCallException;
use JsonException;
use Myxa\Http\Response as HttpResponse;
+use Myxa\Http\StreamWriterInterface;
use Myxa\Support\Facades\Response as ResponseFacade;
use PHPUnit\Framework\Attributes\CoversClass;
use PHPUnit\Framework\TestCase;
@@ -99,6 +100,40 @@ public function testFacadeDelegatesToCurrentResponseInstance(): void
}
}
+ public function testFacadeSupportsStreamingResponses(): void
+ {
+ $response = new HttpResponse();
+
+ ResponseFacade::setResponse($response);
+ ResponseFacade::streaming(static function (StreamWriterInterface $stream): void {
+ $stream->write('hello');
+ $stream->write(' world');
+ }, 202, [
+ 'Content-Type' => 'text/plain; charset=UTF-8',
+ ]);
+
+ self::assertSame($response, ResponseFacade::getResponse());
+ self::assertTrue($response->isStreaming());
+ self::assertSame(202, ResponseFacade::statusCode());
+ self::assertSame('text/plain; charset=UTF-8', ResponseFacade::header('content-type'));
+
+ if (function_exists('header_remove')) {
+ header_remove();
+ }
+
+ ob_start();
+ ob_start();
+ ResponseFacade::send();
+ ob_end_clean();
+ $output = ob_get_clean();
+
+ self::assertSame('hello world', $output);
+
+ if (function_exists('header_remove')) {
+ header_remove();
+ }
+ }
+
public function testFacadeMagicCallStaticForwardsToUnderlyingResponse(): void
{
$response = (new HttpResponse())->body('magic');
diff --git a/tests/Unit/Http/ResponseTest.php b/tests/Unit/Http/ResponseTest.php
index 1e0e252..c3817c9 100644
--- a/tests/Unit/Http/ResponseTest.php
+++ b/tests/Unit/Http/ResponseTest.php
@@ -7,10 +7,13 @@
use InvalidArgumentException;
use JsonException;
use Myxa\Http\Response;
+use Myxa\Http\StreamWriter;
+use Myxa\Http\StreamWriterInterface;
use PHPUnit\Framework\Attributes\CoversClass;
use PHPUnit\Framework\TestCase;
#[CoversClass(Response::class)]
+#[CoversClass(StreamWriter::class)]
final class ResponseTest extends TestCase
{
public function testResponseTracksStatusHeadersAndBody(): void
@@ -122,6 +125,76 @@ public function testResponseSendOutputsBodyAndHandlesSameSiteCookies(): void
}
}
+ public function testResponseCanStreamContentWhenSent(): void
+ {
+ $response = (new Response('stale'))
+ ->setHeader('Content-Length', '999')
+ ->streaming(static function (StreamWriterInterface $stream): void {
+ $stream->write('chunk-1');
+ $stream->write('chunk-2');
+ }, 206, [
+ 'Content-Type' => 'text/event-stream',
+ 'X-Accel-Buffering' => 'no',
+ ]);
+
+ self::assertTrue($response->isStreaming());
+ self::assertSame(206, $response->statusCode());
+ self::assertSame('', $response->content());
+ self::assertFalse($response->hasHeader('Content-Length'));
+ self::assertSame('text/event-stream', $response->header('content-type'));
+ self::assertSame('no', $response->header('x-accel-buffering'));
+
+ if (function_exists('header_remove')) {
+ header_remove();
+ }
+
+ ob_start();
+ ob_start();
+ $response->send();
+ ob_end_clean();
+ $output = ob_get_clean();
+
+ self::assertSame('chunk-1chunk-2', $output);
+
+ if (function_exists('header_remove')) {
+ header_remove();
+ }
+ }
+
+ public function testBodySwitchesResponseBackFromStreamingMode(): void
+ {
+ $response = (new Response())
+ ->streaming(static function (StreamWriterInterface $stream): void {
+ $stream->write('stream');
+ })
+ ->body('plain');
+
+ self::assertFalse($response->isStreaming());
+
+ ob_start();
+ $response->send();
+ $output = ob_get_clean();
+
+ self::assertSame('plain', $output);
+ }
+
+ public function testStreamWriterCanBeFlushedExplicitly(): void
+ {
+ $response = (new Response())
+ ->streaming(static function (StreamWriterInterface $stream): void {
+ $stream->flush();
+ $stream->write('chunk');
+ });
+
+ ob_start();
+ ob_start();
+ $response->send();
+ ob_end_clean();
+ $output = ob_get_clean();
+
+ self::assertSame('chunk', $output);
+ }
+
public function testResponseRejectsInvalidStatusCode(): void
{
$this->expectException(InvalidArgumentException::class);