skip to content

In a Laravel streamed response, why do session writes inside the stream callback get lost, and what happens to an exception thrown there?

level: seniorimportance: should knowfreq 22%

answer

  1. the callback runs during send()
  2. kernel handle() has already returned
  3. StartSession saved the session before
  4. status and headers already sent
  5. StreamedResponseException, eventStream report()

basics

~20 s

A streamed response's callback runs during send(), after the HTTP kernel and all middleware, including StartSession's save, have finished. Session changes made there are never persisted, and an exception cannot change the already-sent 200 status.

solid answer

~40 s

`public/index.php` calls `$app->handleRequest()`, which runs `$kernel->handle($request)->send()` and then `terminate()`. `handle()` runs the whole middleware pipeline and returns the `StreamedResponse` object; `StartSession` has already stored the current URL, attached the session cookie and called `saveSession()` by then. The callback only executes inside `send()`, so `session()->put('export_done', true)` there changes the in-memory session after it was saved and is lost; set session state before returning the response, or record it in the database or cache. Exceptions are also outside the pipeline: the status and headers are already out. For `streamDownload()` Laravel rethrows as `StreamedResponseException`, which is reported and renders an empty body. `eventStream()` catches and `report()`s the error, ending the stream without its end marker. A plain `stream()` callback has no such guard, so handle errors inside it.

code

php · 20 lines
php
<?php

use App\Models\Export;
use Illuminate\Support\Facades\Gate;
use Symfony\Component\HttpFoundation\StreamedResponse;

class SalesExportController
{
    public function __invoke(): StreamedResponse
    {
        Gate::authorize('export-sales');                          // can still become a 403
        $export = Export::create(['user_id' => auth()->id(), 'status' => 'running']);
        session()->flash('status', 'Your export has started.');   // saved by StartSession

        return response()->streamDownload(function () use ($export) {
            // ... write rows ...
            $export->update(['status' => 'finished']);          // durable, not session
        }, 'sales-2026.csv');
    }
}

go deeper

for a junior

Remember that the stream callback runs after the controller has returned, so do session work and checks before returning the streamed response.

for a middle

Explain handleRequest's handle, send, terminate order and why StartSession has already saved the session when the callback runs.

for a senior

Front-load authorization, validation and session writes, persist end results durably, and know how streamDownload, eventStream and stream each surface errors.

for a principal

Establish rules for streamed endpoints, what must be decided before the first byte and how truncation is detected, so failures stay observable and recoverable.

## Where the callback sits in the request Laravel's front controller ends with one call, `$app->handleRequest(Request::capture())`, and `Application::handleRequest()` is three lines: 1. `$response = $kernel->handle($request)` runs global middleware, the router, route middleware and the controller, and returns a response object. 2. `->send()` writes status, headers and body to the client. 3. `$kernel->terminate($request, $response)` runs terminable middleware and terminating callbacks. For an ordinary response the body already exists after step 1. For a `StreamedResponse` the body is a **callback that runs in step 2**. By then every middleware has finished its "after" part. The general boot sequence is the lifecycle's subject; here what matters is the consequences for streams. ## Consequence 1: session writes are lost `Illuminate\Session\Middleware\StartSession` handles a request like this: - start the session and attach it to the request; - `$response = $next($request)`, which reaches the controller that returns the `StreamedResponse`; - store the current URL, add the session cookie to the response; - **`saveSession()`**, writing the session to its driver; - return the response. Only afterwards does `send()` run your callback. So in a sales export: ```php return response()->streamDownload(function () { // ... write a million rows ... session()->flash('status', 'Export finished'); // never saved }, 'sales.csv'); ``` the flash message changes the in-memory session after it was persisted, and it is gone. The same applies to `Cookie::queue()` inside the callback: the queue middleware already copied cookies onto the response, and the headers are already sent anyway. Fixes: - Write session state **before** returning the response, if it does not depend on the export's result. - Record results that are only known at the end somewhere durable, such as an `exports` table row or a cache key, and let the next page read it. ## Consequence 2: exceptions cannot become error pages The exception handler normally turns an exception into a response inside `handle()`. An exception in a stream callback happens after that, and after the status line (`200`) and headers were written. Laravel's streaming helpers each deal with this: | Helper | On an exception in your code | |---|---| | `streamDownload()` | rethrown as `StreamedResponseException`; the uncaught-exception handler reports it, and its `render()` returns an empty `Response`, so no error HTML lands in the file | | `eventStream()` | caught inside the stream and passed to `report()`; the loop ends and the `</stream>` end marker is **not** sent | | `stream()` | no wrapper; the exception escapes `send()`, is reported by the global handler, and an error page may be written after the partial body | In every case the client sees a 200 with a body that stops early. That makes these practices important: 1. Validate inputs, check authorization and run the first query **before** returning the streamed response, so predictable failures still produce proper 4xx or 5xx responses. 2. Wrap risky work inside a plain `stream()` callback in `try`/`catch`, and `report()` the error yourself. 3. Give consumers a way to detect truncation: a final marker row, a row count, or an end event. ## Other things that are already done by the time the callback runs - Authentication and authorization middleware have run, which is correct, but a policy check done lazily inside the callback can no longer return a 403. - Response-modifying middleware, such as ones that add headers after `$next()`, have already acted on the empty streamed response object. - Terminable middleware and `terminating()` callbacks run only after the stream completes, so they can be delayed by minutes. ## How to test for it A feature test that calls the export route can read the streamed body with the test response's streamed-content helper and then assert on the session and database. Asserting that the flash message is **absent** documents the pitfall for the next developer, and asserting that the `exports` row is marked finished proves the durable path works. For error handling, make the query throw inside the callback and assert that the exception was reported rather than rendered into the body. ## What a senior answer shows Placing the callback correctly (inside `send()`, after `handle()` and before `terminate()`), naming `StartSession::saveSession()` as the reason session writes vanish, and describing each helper's error path, including the missing end marker for `eventStream()`.

  • Why does `Gate::authorize()` inside a stream callback no longer give the user a 403 page?
    It throws an `AuthorizationException` during `send()`, after the 200 status and headers were written. The exception handler cannot replace the response any more, so the client gets a 200 with a cut-off body and the error ends up only in the logs. Authorize before returning the streamed response.
  • When do terminating callbacks run for a request that streams a two-minute export?
    After `send()` returns, which is when the stream callback finishes. `Application::handleRequest()` calls `$kernel->terminate()` only after sending, so terminable middleware and `terminating()` callbacks are delayed until the whole export has been written.

saying these in an interview costs you the question

  • Session writes in a stream callback are saved because the session middleware runs last.
  • The exception handler replaces a failed stream with a 500 error page.
  • The stream callback runs inside the controller before middleware finishes.
  • streamDownload() retries the callback automatically when it throws.
  • eventStream() still sends its </stream> end marker after an exception.