A Laravel app's Cookie::queue() call works on web routes but sets nothing on an api.php route, and ->withCookie() on a download() response crashes; why?
answer
- which middleware group runs
- api group lacks AddQueuedCookiesToResponse
- BinaryFileResponse has no ResponseTrait
- headers->setCookie() works on any response
- Call to undefined method ...::withCookie()
basics
~10 sQueued cookies are copied onto responses by AddQueuedCookiesToResponse, which is in the web group but not the api group. withCookie() comes from Laravel's ResponseTrait, which Symfony's BinaryFileResponse returned by download() does not use.
solid answer
~40 s`Cookie::queue()` only stores the cookie in the `CookieJar`; the `AddQueuedCookiesToResponse` middleware is what copies queued cookies onto the response, and in Laravel 13's default groups it is registered in `web` but not in `api`. A route in `routes/api.php` runs the `api` group, so the queue fills and is never read. Fix it by attaching the cookie to the response you return, or by adding that middleware to the route. The crash is a different cause: `response()->download()` returns Symfony's `BinaryFileResponse`, and `withCookie()`, `cookie()` and `withHeaders()` live in Laravel's `ResponseTrait`, used only by `Illuminate\Http\Response`, `JsonResponse` and `RedirectResponse`. PHP throws `Call to undefined method`. Use `$response->headers->setCookie(cookie(...))`, or queue the cookie on a web route.
code
php · 15 lines<?php
use Illuminate\Support\Facades\Route;
use Illuminate\Cookie\Middleware\AddQueuedCookiesToResponse;
// routes/api.php: attach the cookie directly, no queue needed.
Route::post('/seat-holds', function () {
$hold = SeatHold::createForCurrentBuyer();
return response()->json($hold, 201)->withCookie('seat_hold', $hold->id, 10);
});
// Or let one route honour queued cookies.
Route::post('/seat-holds/renew', RenewSeatHold::class)
->middleware(AddQueuedCookiesToResponse::class);go deeper
Remember that Cookie::queue() needs a middleware from the web group to deliver the cookie, and that download() returns a different response class.
Explain which classes use ResponseTrait, which factory methods return Symfony responses, and how AddQueuedCookiesToResponse copies queued cookies.
Diagnose from route:list and the raw Set-Cookie header, fix with headers->setCookie() or explicit attachment, and weigh encryption before adding cookie middleware to API routes.
Decide whether API clients should depend on cookies at all, and document which middleware groups own cookie behaviour so teams do not rediscover it per endpoint.
## Two symptoms, two causes A concert-venue app has two cookie bugs in the same week: 1. The mobile client calls `POST /api/seat-holds`. The controller calls `Cookie::queue('seat_hold', $hold->id, 10)`, but the response carries no `Set-Cookie` header. The same code on a `routes/web.php` route works. 2. The ticket download action does `return response()->download($path, 'ticket.pdf')->withCookie('downloaded', '1', 5);` and throws `Error: Call to undefined method Symfony\Component\HttpFoundation\BinaryFileResponse::withCookie()`. They look related, but they come from two different design facts. ## Cause 1: the queue is emptied by a web-group middleware `Cookie::queue()` writes into the singleton `Illuminate\Cookie\CookieJar`; nothing is sent at that point. Something has to read the jar and copy its contents onto the response. That something is `Illuminate\Cookie\Middleware\AddQueuedCookiesToResponse`, whose `handle()` calls `$next($request)` and then `$response->headers->setCookie($cookie)` for every entry in `getQueuedCookies()`. In Laravel 13 the default groups are built in `Illuminate\Foundation\Configuration\Middleware::getMiddlewareGroups()`: | Group | Cookie-related middleware | |---|---| | `web` | `EncryptCookies`, `AddQueuedCookiesToResponse`, then `StartSession` and the rest | | `api` | none: optionally Sanctum's stateful middleware and `throttle:api`, then `SubstituteBindings` | Routes in `routes/api.php` (enabled with `withRouting(api: ...)` in `bootstrap/app.php`, or by `php artisan install:api`) get the `api` group. The queued cookie is stored and then forgotten when the request ends. The fixes, in order of preference: - **Attach it to the response you return**: `return response()->json($hold, 201)->withCookie('seat_hold', $hold->id, 10);`. This does not depend on any middleware. - **Add the middleware to the route** if several services queue cookies: `->middleware(AddQueuedCookiesToResponse::class)`. Note that without `EncryptCookies` those cookie values travel unencrypted, which is a decision in its own right. - **Question the design.** A token-authenticated API client often ignores cookies entirely; returning the hold id in the JSON body may be what the client actually needs. ## Cause 2: not every response class has Laravel's helpers The fluent helpers `header()`, `withHeaders()`, `cookie()`, `withCookie()` and `withoutCookie()` are defined in `Illuminate\Http\ResponseTrait`. Only three classes use that trait: `Illuminate\Http\Response`, `Illuminate\Http\JsonResponse` and `Illuminate\Http\RedirectResponse`. Several response factory methods return plain Symfony classes instead: | Factory method | Returns | Has `withCookie()`? | |---|---|---| | `make()`, `view()`, `noContent()` | `Illuminate\Http\Response` | yes | | `json()` | `Illuminate\Http\JsonResponse` | yes | | `redirectTo()`, `redirect()` helpers | `Illuminate\Http\RedirectResponse` | yes | | `download()`, `file()` | `Symfony\...\BinaryFileResponse` | no | | `stream()`, `streamDownload()`, `eventStream()` | `Symfony\...\StreamedResponse` | no | Those Symfony classes define no `withCookie()` method, so PHP throws an `Error` at the chained call. Every Symfony response does have a public `headers` bag, and that is the portable way in: ```php $response = response()->download($path, 'ticket.pdf'); $response->headers->setCookie(cookie('downloaded', '1', 5)); return $response; ``` On a `web` route, `Cookie::queue('downloaded', '1', 5)` works too, because the queue middleware uses exactly that `headers->setCookie()` call and does not care which response class it receives. Extra headers for a download go in its third argument, or through `$response->headers->set()`. ## How to diagnose this class of bug quickly 1. Run `php artisan route:list -v` to see the middleware on the failing route. 2. Check the class of the response you are chaining on, not the helper you called. 3. Inspect the raw `Set-Cookie` header with the browser's network panel or `curl -i`, before looking at the browser's cookie store. ## Why it is designed this way The split is deliberate. Laravel's own response classes add convenience on top of Symfony's, but Laravel does not wrap every Symfony class it hands out, because streamed and binary-file responses have their own constructors and sending logic. The `web` and `api` groups are also deliberately different: the `web` group assumes a browser with cookies, sessions and CSRF protection, while the `api` group is kept lean for token-authenticated clients. Knowing which assumptions each group makes is what turns this from a mystery into a two-minute fix, and it prevents the opposite mistake of copying the whole `web` stack onto API routes just to make one cookie work. ## What this shows in an interview The expected insight is that a queued cookie is a promise kept by middleware, so it depends on the route's group, and that Laravel's fluent response helpers belong to Laravel's own response classes. A candidate who reaches for `headers->setCookie()` understands the layer underneath.
- Why doesn't `->withHeaders(['X-Ticket' => $id])` work on `response()->streamDownload(...)` either?`streamDownload()` returns Symfony's `StreamedResponse`, which does not use Laravel's `ResponseTrait`, so `withHeaders()` does not exist on it. Pass the headers in the method's headers argument or set them on `$response->headers` before returning.
- If you add `AddQueuedCookiesToResponse` to an api route, what else changes compared with the web group?Only the queue is honoured. `EncryptCookies` is still missing, so the cookie value is sent and read as plain text, and there is no session or CSRF middleware. Decide deliberately whether that is acceptable, or attach the cookie explicitly and keep the api group lean.
saying these in an interview costs you the question
- Cookie::queue() writes the Set-Cookie header the moment it is called.
- The api middleware group includes AddQueuedCookiesToResponse like the web group.
- Every object returned by the response() factory has withCookie() and withHeaders().
- A Symfony response cannot carry cookies at all, so downloads never set one.
- The fix is to call PHP's setcookie() inside the controller.