skip to content

On a Laravel 13 ticket-resale site, a header set in a controller never reaches the browser; where in the lifecycle can the response still change, and where is it too late?

level: seniorimportance: should knowfreq 34%

answer

  1. the returned object is the response
  2. middleware on the way out can replace it
  3. exceptions are rendered into a new response
  4. RequestHandled fires before send()
  5. terminating() runs after send()

basics

~20 s

Only the returned object becomes the response, and outbound middleware or exception rendering can replace it. It can change until send(), with a RequestHandled listener as the last hook; terminable middleware and terminating() callbacks run after send(), too late.

solid answer

~40 s

Trace the object. A header set on a response the controller does not return (say, `response()->json(...)->header(...)` stored in a variable while a view is returned) is lost, and so is one set on the request. On the way out, route and then global middleware each receive the response and can add headers or return a different one; a middleware returning a redirect discards the original. If anything throws after the header was set, the kernel's `handle()` renders the exception into a new response. After the pipeline, `handle()` fires `RequestHandled`, whose listeners can still modify the response. Then `handleRequest()` calls `send()`, which writes headers and body. Everything after that (the `Terminating` event, terminable middleware's `terminate()`, callbacks from `app()->terminating()`, jobs dispatched `afterResponse()`) runs too late to change what the browser gets.

code

php · 21 lines
php
<?php

namespace App\Http\Controllers;

use Illuminate\Http\Request;

class CheckoutController extends Controller
{
    public function show(Request $request)
    {
        $position = 42;

        // Lost: runs after send(), when headers are already out
        app()->terminating(fn () => response()->noContent()->header('X-Queue-Position', $position));

        // Reaches the browser: set on the object that is returned
        return response()
            ->view('checkout.show', ['position' => $position])
            ->header('X-Queue-Position', (string) $position);
    }
}

go deeper

for a junior

Remember to set headers on the response object you return, and that code running after send() cannot change it.

for a middle

Walk the outbound path: controller return, route and global middleware, exception rendering, RequestHandled, then send and terminate.

for a senior

Trace a lost header methodically with a RequestHandled listener and outbound middleware logging, and design headers that must survive errors and redirects.

for a principal

Decide where response-shaping policy lives (middleware versus listeners versus the edge) so headers stay consistent across teams and error paths.

## The scenario A ticket-resale site adds an `X-Queue-Position` header in the checkout controller so the front end can show where a buyer stands in the queue. In the browser's network panel the header is missing. Laravel's lifecycle has a fixed set of places where a response can still change, and knowing them turns this into a short trace. ## Where the response can still change In Laravel 13 the response object moves through these stages, in this order: 1. **The controller's return value.** `Router::toResponse()` turns whatever the controller returns into the response; headers set anywhere else in the action do not travel with it. 2. **Route middleware on the way out.** Each middleware in the route's stack receives the response after `$next($request)` and can add headers or return a different response. 3. **Global middleware on the way out.** Same idea, for the middleware that wraps every request. 4. **Exception rendering.** If anything throws, the kernel's `handle()` catches it, reports it and renders a fresh response. 5. **`RequestHandled` listeners.** `handle()` dispatches `Illuminate\Foundation\Http\Events\RequestHandled` with the request and response before returning, so a listener can still set headers. 6. **`send()`.** `Application::handleRequest()` calls `send()`, which writes the status line, headers and body. After step 6 the headers have been sent. ## Where it is too late `handleRequest()` then calls the kernel's `terminate()`, which runs: - the `Terminating` event; - `terminate()` on terminable middleware; - callbacks registered with `app()->terminating(...)`, which is also how jobs dispatched with `afterResponse()` run; - handlers registered for slow requests. All of these see the final response object, and changing it there has no effect on the browser. The middleware docs say that under FastCGI the `terminate` work runs after the response has been sent to the browser. ## The usual culprits | Symptom | Cause | |---|---| | Header missing on every request | Set on a response the controller does not return, or on `$request->headers` | | Header missing only on redirects | A middleware returned a new redirect response | | Header missing when something fails | An exception was rendered into a new response | | Header set in code that "runs later" | Added in `terminating()`, a terminable middleware or an after-response job | ## A correct version - Set the header on the object you return: `return response()->view('checkout', $data)->header('X-Queue-Position', $pos);`. - For every response, use a middleware that adds it after `$next($request)`. - For headers that must survive errors, add them in exception rendering as well, or in a `RequestHandled` listener, which receives the rendered response. ## How to trace it quickly 1. Log the response class and headers in a `RequestHandled` listener; if the header is there, something outside Laravel (a proxy or CDN) removes it. 2. If it is not there, log in the controller just before `return`, then in each outbound middleware, to find where it disappears. 3. Check the exception log for the same request; a rendered exception explains a changed status as well as the missing header. ## When Laravel sent it but the browser did not get it If the `RequestHandled` listener shows the header, the application did its job, and the loss happens between the server and the browser. Things to check, without changing Laravel code: - a reverse proxy or CDN in front of the site that only forwards an allow-list of headers; - a browser-side script reading a cross-origin response, where only exposed headers are visible to JavaScript; - a cached copy of an older response served by an intermediate cache. Each of these is a separate subject; the point for the lifecycle is to prove first whether the header left Laravel at all. ## Common misreadings - "`terminating()` is a good place to add headers": it runs after `send()`. - "Middleware only runs before the controller": the code after `$next()` runs on the way out. - "The exception handler keeps the controller's headers": it builds a new response.

  • Can a RequestHandled listener change the response the browser receives?
    Yes. The kernel's `handle()` dispatches `RequestHandled` with the final response, including one rendered from an exception, before returning it to `handleRequest()`, which only then calls `send()`. A listener that sets `$event->response->headers` therefore affects what is sent, which makes it a last-resort hook for headers that must appear on every response.
  • Why might the header disappear only when checkout fails validation?
    A failed validation throws a `ValidationException`. The kernel catches it and renders a new response, a redirect back for a browser form or a 422 JSON response for a JSON request, built from scratch rather than from the controller's response. Any header added before the exception was thrown was on an object that is never sent.

Think of a box going through a shipping depot. You can add labels at the packing table (the controller), at each checkpoint on the way to the loading dock (middleware), or even swap the box for another one. Once the truck leaves (send()), writing a new label on the depot's copy of the paperwork (terminating callbacks) changes nothing the customer receives.

saying these in an interview costs you the question

  • Headers can still be added inside app()->terminating() callbacks
  • Setting a header on $request->headers sends it to the browser
  • Middleware only sees the request, never the response
  • The exception handler reuses the controller's response object
  • RequestHandled fires after the response has been sent