skip to content

In Laravel, how does response()->eventStream() send a live progress feed, and what are StreamedEvent and the </stream> end marker for?

level: middleimportance: should knowfreq 30%

answer

  1. server-sent events from a generator
  2. text/event-stream, no-cache, X-Accel-Buffering: no
  3. default event name update
  4. yield new StreamedEvent(event:, data:)
  5. endStreamWith defaults to </stream>

basics

~10 s

response()->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
<?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

for a junior

Know that eventStream() turns yielded values into server-sent events, named update by default, and ends with a </stream> message.

for a middle

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.

for a senior

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.

for a principal

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.