In Laravel, how does response()->eventStream() send a live progress feed, and what are StreamedEvent and the </stream> end marker for?
answer
- server-sent events from a generator
- text/event-stream, no-cache, X-Accel-Buffering: no
- default event name update
- yield new StreamedEvent(event:, data:)
- endStreamWith defaults to </stream>
basics
~10 sresponse()->eventStream() writes each value a generator yields as a server-sent event named update, JSON-encoding arrays. A yielded StreamedEvent sets the event name, and a final </stream> message tells the client the stream ended.
solid answer
~40 s`eventStream(Closure $callback, array $headers = [], StreamedEvent|string|null $endStreamWith = '</stream>')` returns a `StreamedResponse` with `Content-Type: text/event-stream`, `Cache-Control: no-cache` and `X-Accel-Buffering: no`. For each value your generator yields, it stops if `connection_aborted()` reports the client gone, otherwise writes `event: update` and `data: ...` followed by a blank line, and flushes. Strings and numbers are sent as-is; anything else goes through `Js::encode()`. Yielding `new StreamedEvent(event: 'progress', data: [...])` sets the event name. After the loop Laravel sends the end marker, by default an `update` event whose data is `</stream>`; the `@laravel/stream-*` hooks treat it as the end signal, and a plain `EventSource` client should close on it, because otherwise the browser reconnects. Pass a `StreamedEvent` to customise it or `null` to omit it.
code
php · 15 lines<?php
use Illuminate\Http\StreamedEvent;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Route;
Route::get('/exports/{export}/progress', function (string $export) {
return response()->eventStream(function () use ($export) {
do {
$state = Cache::get("export:{$export}", ['done' => 0, 'total' => 0, 'finished' => false]);
yield new StreamedEvent(event: 'progress', data: $state);
sleep(1);
} while (! $state['finished']);
}, endStreamWith: new StreamedEvent(event: 'done', data: 'ok'));
});go deeper
Know that eventStream() turns yielded values into server-sent events, named update by default, and ends with a </stream> message.
Explain the headers it sets, StreamedEvent for custom names, Js::encode for non-strings, connection_aborted() checks, and how endStreamWith customises or removes the end marker.
Design feeds that end cleanly, handle generator errors without losing the end marker, read progress from shared state, and bound how long a feed holds a worker.
Decide when a request-held SSE feed is acceptable versus broadcasting over WebSockets, weighing worker capacity against infrastructure.
## The use case The sales-report export runs for a minute or two, and the finance dashboard shows a live progress bar: "120,000 of 1,000,000 rows". Server-sent events (SSE) fit: one long HTTP response in which the server writes small text messages that the browser's `EventSource` delivers as events. Laravel packages the server side as `response()->eventStream()`. The protocol duties of an SSE server in general belong to SSE itself; here the subject is what Laravel's method does for you. ## The method ```php public function eventStream(Closure $callback, array $headers = [], StreamedEvent|string|null $endStreamWith = '</stream>') ``` It calls `stream()` with a closure that does the following: 1. Calls your closure and iterates what it returns, which should be a generator that `yield`s messages. 2. Before each message, checks `connection_aborted()` and **stops** if the client has gone. 3. Uses the event name `update`, unless the yielded value is an `Illuminate\Http\StreamedEvent`, in which case it takes its `event` and `data` properties. 4. Leaves strings and numbers as they are and encodes anything else with `Illuminate\Support\Js::encode()`, which produces JSON with HTML-safe flags. 5. Writes `event: <name>`, then `data: <message>`, then a blank line, and calls `ob_flush()` and `flush()`. 6. After the loop, if `$endStreamWith` is filled, sends one more event the same way: by default `event: update` with `data: </stream>`. 7. Catches any `Throwable` from the whole block and passes it to `report()`. The response is always status 200 with these headers merged over yours: `Content-Type: text/event-stream`, `Cache-Control: no-cache` and `X-Accel-Buffering: no`. ## `StreamedEvent` `Illuminate\Http\StreamedEvent` is a tiny value object with a constructor `(string $event, mixed $data)`. Yield it when the client listens for specific event names: ```php yield new StreamedEvent(event: 'progress', data: ['done' => $done, 'total' => $total]); ``` On the client, `source.addEventListener('progress', ...)` receives it. Without it every message is an `update` event. ## The end marker An `EventSource` treats a closed connection as a network hiccup and **reconnects** after a delay, which would restart your generator. Laravel therefore sends a sentinel when the generator finishes: | `$endStreamWith` value | What the client receives last | |---|---| | omitted (`'</stream>'`) | `event: update` / `data: </stream>` | | `new StreamedEvent(event: 'done', data: 'ok')` | `event: done` / `data: ok` | | `null` or `''` | nothing extra | The `useEventStream` hooks from `@laravel/stream-react`, `@laravel/stream-vue` and `@laravel/stream-svelte` default their `endSignal` option to `</stream>`. With a raw `EventSource`, check for the marker and call `close()`. Because the end marker is sent inside the same `try` block, an exception thrown by your generator skips it: the error is reported, the stream ends without the sentinel, and a raw `EventSource` will reconnect. Handle expected failures inside the generator and yield an explicit error event instead. ## Practical notes for a progress feed - The progress itself should come from somewhere shared, such as a cache key a queued job updates; the generator loops, reads it, yields, and sleeps briefly. - Yield arrays for structured data. A string that contains a newline would break the `data:` line, since Laravel writes it verbatim after `data: `. - Every open feed holds a PHP worker for its whole life, so keep feeds short-lived and end them when the job finishes. ## Consuming the feed on the client With the official helpers, a React page calls `useEventStream('/exports/42/progress')` from `@laravel/stream-react` and reads `message` as events arrive; Vue and Svelte have equivalent packages. With a raw `EventSource`: 1. create `new EventSource('/exports/42/progress')`; 2. listen for your event names with `addEventListener('progress', ...)`, or for the default `update` events; 3. when the data equals the end marker, or your custom end event fires, call `source.close()`. Forgetting step 3 is the classic bug: the page keeps reconnecting after the job is done, and each reconnection runs the generator again on the server. ## What interviewers look for The headers it sets, the default `update` event name, `StreamedEvent` for custom names, the `</stream>` sentinel and why it exists, and the `connection_aborted()` check that stops work for departed clients.
- Why does Laravel send `</stream>` at the end instead of simply closing the connection?Browsers' `EventSource` treats a closed connection as a temporary failure and reconnects automatically, which would start the generator again. An explicit final message lets the client recognise a normal end and call `close()`. Laravel's `@laravel/stream-*` hooks use `</stream>` as their default `endSignal`.
- What happens to a feed whose browser tab was closed?Before writing each message, `eventStream()` checks `connection_aborted()` and breaks out of the loop when PHP has detected the disconnect, so the generator stops producing. Detection depends on a write having failed, so a generator that sleeps for long periods between yields notices later.
saying these in an interview costs you the question
- eventStream() names every event after the route unless configured otherwise.
- Arrays yielded to eventStream() must be json_encoded by hand first.
- The </stream> marker is part of the SSE standard and closes EventSource automatically.
- eventStream() keeps running for clients that disconnected until the generator finishes.
- You can return status 202 from eventStream() through its status argument.