In Laravel, what changes when response()->stream() receives a generator closure instead of a plain closure that echoes output?
answer
- who flushes each chunk
- a StreamedResponse either way
- yield: flush after every chunk for you
- adds X-Accel-Buffering: no
- plain closure: ob_flush() and flush() yourself
basics
~20 sBoth return a Symfony StreamedResponse. With a generator, Laravel echoes each yielded chunk, flushes PHP's output buffer after it and adds X-Accel-Buffering: no; with a plain closure nothing is flushed or added unless you do it yourself.
solid answer
~40 s`response()->stream($callback, $status = 200, $headers = [])` checks the callback with reflection. If it is a **generator** function, Laravel wraps it in a `StreamedResponse` whose callback loops over the generator, `echo`es each chunk, calls `ob_flush()` when an output buffer is active and then `flush()`, and it merges `X-Accel-Buffering: no` into the headers so an Nginx proxy passes chunks through. A **plain closure** becomes a `StreamedResponse` as-is: whatever it echoes can sit in PHP's output buffer (4096 bytes in the shipped php.ini files) or the proxy until the end, so you call `ob_flush()` and `flush()` after each piece and set `X-Accel-Buffering: no` yourself, as the docs' example does. Either way the callback runs when the response is sent, not when the controller returns.
code
php · 15 lines<?php
use App\Models\Region;
use Illuminate\Support\Facades\Route;
Route::get('/reports/regions.ndjson', function () {
return response()->stream(function (): Generator {
foreach (Region::orderBy('name')->cursor() as $region) {
yield json_encode([
'region' => $region->name,
'total' => $region->salesTotal(),
])."\n";
}
}, 200, ['Content-Type' => 'application/x-ndjson']);
});go deeper
Remember that response()->stream() sends output in pieces, and that yielding chunks from a generator makes Laravel flush them for you.
Explain the reflection check, what the generator wrapper does (echo, ob_flush, flush, X-Accel-Buffering: no), and what a plain closure must do by hand.
Choose the generator style for new code, keep status and headers correct before the first byte, and check proxy and PHP buffering when chunks arrive late.
Decide which endpoints may hold a worker open for a stream at all, and standardise one streaming style so buffering behaviour is predictable.
## What `stream()` returns `Illuminate\Routing\ResponseFactory::stream($callback, $status = 200, $headers = [])` always returns a Symfony `StreamedResponse`. Such a response has no body string; it holds a callback that is executed when the response is sent, and whatever the callback writes to PHP's output goes to the client. The controller that returns it has already finished by then. The difference between the two callback styles is **who pushes the bytes out**. ## The plain-closure path ```php return response()->stream(function (): void { foreach ($this->regions() as $region) { echo $region->summaryLine()."\n"; ob_flush(); flush(); } }, 200, ['X-Accel-Buffering' => 'no']); ``` Laravel passes this closure straight to `new StreamedResponse($callback, $status, $headers)`. Nothing else is added: - **No flushing.** Output first lands in PHP's own output buffer; the shipped `php.ini-production` and `php.ini-development` both set `output_buffering = 4096`. Until you call `ob_flush()` (for PHP's buffer) and `flush()` (for the SAPI and web server), small chunks may not leave the process. - **No proxy header.** An Nginx reverse proxy buffers upstream responses by default; the `X-Accel-Buffering: no` response header tells it not to for this response. You add it yourself. ## The generator path ```php return response()->stream(function (): Generator { foreach ($this->regions() as $region) { yield $region->summaryLine()."\n"; } }); ``` `stream()` inspects the callback with `new ReflectionFunction($callback)` and `isGenerator()`. For a generator it: 1. wraps it in a new closure that runs `foreach ($callback() as $chunk)`; 2. `echo`es each chunk; 3. calls `ob_flush()` if `ob_get_level() > 0`, then `flush()`; 4. merges `X-Accel-Buffering: no` into the headers you passed. Under Octane (detected through `$_SERVER['LARAVEL_OCTANE']`) the generator is handed to the `StreamedResponse` directly, still with the header, so the Octane server can consume it. ## Side by side | | Plain closure | Generator closure | |---|---|---| | Response class | `StreamedResponse` | `StreamedResponse` | | Who writes output | your `echo` | Laravel echoes each `yield` | | Flush after each chunk | you call `ob_flush()` and `flush()` | done for you | | `X-Accel-Buffering: no` | only if you pass it | added automatically | | Typical use | legacy code, libraries that write to output | new code, token or row streams | ## Things that are the same either way - **Status and headers are fixed up front.** They are sent before the first byte of the body, so an error half-way through cannot turn a 200 into a 500. - **No `Content-Length`.** The size is unknown in advance, so the body goes out in chunks. - **Laravel's fluent helpers are missing.** `StreamedResponse` is a Symfony class without `withHeaders()` or `withCookie()`; pass headers in the third argument. - **The callback runs late.** It executes during `send()`, after the controller and middleware have returned. ## When to use `stream()` at all For a sales dashboard, `stream()` suits a plain-text or NDJSON feed of per-region totals computed one after another, or relaying tokens from an upstream API. File downloads have `streamDownload()`, JSON documents have `streamJson()`, and browser push feeds have `eventStream()`; all of them build on the same streamed response. ## Checking that chunks really arrive When a stream seems to arrive all at once, test each layer separately: - Run `curl -N` against the app server directly, bypassing any proxy. If chunks arrive there, PHP and Laravel are fine and the proxy is buffering. - Check the response headers for `X-Accel-Buffering: no`; on the plain-closure path it is present only if you passed it. - Check whether compression is applied to the response somewhere on the way, since a compressor typically waits for enough data before emitting output. - Make sure each chunk is actually flushed: on the plain-closure path, a missing `flush()` is the usual culprit. Fixing the right layer is quicker than raising or lowering buffer sizes everywhere. ## What interviewers listen for That both styles give a `StreamedResponse`, that only the generator style flushes and disables proxy buffering for you, and that the plain style needs `ob_flush()`, `flush()` and the header by hand. Knowing the 4096-byte `output_buffering` default explains why "nothing arrives until the end" is so common.
- Why does the docs' plain-closure example call both `ob_flush()` and `flush()`?They empty different layers. `ob_flush()` pushes PHP's user-level output buffer, which `output_buffering = 4096` in the shipped php.ini files enables, down to the SAPI. `flush()` then asks the SAPI and web server to send what they hold. Calling only one can leave chunks stuck in the other layer.
- Can you override the `X-Accel-Buffering` header on the generator path?Not through the headers argument. `stream()` builds the headers with `array_merge($headers, ['X-Accel-Buffering' => 'no'])`, so the framework's value is merged last and wins. If you need proxy buffering on, use a plain closure and control the headers and flushing yourself.
saying these in an interview costs you the question
- A generator passed to response()->stream() is collected into one string before sending.
- A plain closure's echo output is flushed to the client after every statement automatically.
- response()->stream() returns an Illuminate\Http\Response you can chain withHeaders() on.
- X-Accel-Buffering is a PHP ini setting rather than a response header.
- The stream callback runs inside the controller, before middleware sees the response.