livewire #10327: [4.x] Prevent file download responses from leaking into action returns
Contributed by joshhanley
Part of the livewire v4.4.7 release
Category: fix
Pull request #10327 tightens up what Livewire puts on the wire when a component action returns a file download. Livewire already converts download responses into a dedicated, JSON-safe download effect for the browser — but until now, the original Symfony response object was also copied into the effects.returns array, leaking response internals into the update payload. On the 4.x branch, those return slots are now normalized to null. If you're upgrading from v3.8.10, downloads work exactly as they always have; the only thing that changes is a cleaner JSON payload.
What changed
The fix lives in HandleComponents::callMethods(), the mechanism that executes action calls during an update request. After the method-call hook pipeline finishes, the return value is now inspected: if it is a Symfony\Component\HttpFoundation\StreamedResponse or BinaryFileResponse — the response types produced by Storage::download(), response()->download(), and response()->streamDownload() — it is replaced with null before being appended to effects.returns. The check runs on both code paths: the normal invocation path and the hook early-return path.
Two design points are worth noting:
SupportFileDownloadsis untouched. It still detects download responses and converts them into thedownloadeffect (name,content,content-type), which remains the only browser-facing instruction.- The
returnsentry is kept as anullplaceholder rather than dropped, so its index stays aligned with the submittedcalls. The JavaScript side resolves each$wire.call()promise by position, and preserving alignment keeps that mapping correct.
Why couldn't this be done in a hook? Component hook finish callbacks are designed for side effects, and the event bus treats a null hook return as "keep the original forwarded value". So returning null from the file-download hook could never actually scrub the response — the normalization had to happen inside callMethods() itself. This PR also fixes issue #10326, where the leak was reported.
Why it matters
Before this change, the serialized response object — headers bag, original file information, status text, and other internals — ended up in the JSON payload delivered to the browser. That causes three problems:
- Payload bloat. A response wrapper is dramatically heavier than a
null, for data the frontend never uses. - Information exposure. Server-side details such as storage paths and custom headers become visible to anyone inspecting the network payload.
- Confusing client values. A
$wire.call()awaiting a download action resolved with a response-shaped blob instead of something sane.
None of these break a download, which is why the bug slipped through — but all of them are wrong for a framework that prides itself on minimal, purposeful payloads.
Before and after
Take a typical download action:
use Illuminate\Support\Facades\Storage;
use Livewire\Component;
class ExportComponent extends Component
{
public function downloadReport()
{
return Storage::disk('local')->download(
'reports/2024/export.csv',
'report-2024.csv',
['Content-Type' => 'text/csv'],
);
}
}
The effects portion of the update payload previously resembled:
{
"download": {
"name": "report-2024.csv",
"content": "...",
"content-type": "text/csv"
},
"returns": [{
"headers": { "content-type": ["text/csv"] },
"statusText": "OK",
"...": "full serialized response internals"
}]
}
After this fix, the download effect is identical, and the return slot is a clean placeholder:
{
"download": {
"name": "report-2024.csv",
"content": "...",
"content-type": "text/csv"
},
"returns": [null]
}
The browser still triggers the download from the download effect; returns.0 simply holds null so the index alignment with the submitted calls is preserved.
Upgrade impact
For virtually everyone, the impact is nil:
- No component changes required.
Storage::download(),response()->download(), andresponse()->streamDownload()all continue to work exactly as before — same effect, same browser behavior. - Client-side resolved values. If any of your JavaScript awaits a download action's
$wire.call()result, it now resolves withnullinstead of serialized response data. That data was never practically usable, so this should be a non-event. - Payload-level tests. If you have tests asserting on the shape of
effects.returnsfor download actions, update them to expectnull. The PR adds a regression test (test_file_download_responses_are_not_included_in_action_returns) that posts a raw update request and assertscomponents.0.effects.returns.0isnull. - Scope caveat. The normalization covers
StreamedResponseandBinaryFileResponsespecifically. Other download-returning patterns, such as customResponsableclasses handled elsewhere by the downloads feature, are not touched by this PR.
Coming from v3.8.10, the leak existed in 3.x as well, so crossing into the 4.x line means you gain this hygiene fix automatically — no configuration, no migration steps.
Takeaways
- Download responses no longer leak into
effects.returns; those slots are nownull. - The
downloadeffect remains the sole browser-facing instruction and is unchanged. nullplaceholders keepeffects.returnsindexes aligned with submittedcalls, so$wire.call()promise resolution stays correct.- A hook couldn't solve this because the event bus treats hook
nullas "keep the original value" — hence the fix incallMethods(). - Upgrade effort from v3.8.10: none for app code; only update tests that assert on the old payload shape.