skip to content

In Laravel, how do Storage::download() and Storage::response() differ, and what does each cost when the file sits on an S3 disk?

level: middleimportance: should knowfreq 42%

answer

  1. both return a StreamedResponse
  2. inline versus attachment disposition
  3. headers need mimeType() and size()
  4. missing file: UnableToRetrieveMetadata
  5. stream_reads on the s3 disk

basics

~20 s

Both return a StreamedResponse that pipes the file through PHP: response() sends an inline disposition, download() an attachment. On S3 each first looks up the MIME type and size, then relays the object through your server.

solid answer

~40 s

`Storage::response($path, $name, $headers, $disposition = 'inline')` builds a Symfony `StreamedResponse`; `Storage::download($path, $name, $headers)` is the same call with `attachment`. Unless you pass them in `$headers`, Laravel fills `Content-Type` from `mimeType()` and `Content-Length` from `size()`, then writes a `Content-Disposition` with the `$name` and an ASCII fallback. The body is sent later by a callback that opens `readStream()` and copies it to the output with `fpassthru()`. On an S3 disk that means metadata lookups before the response is built and a full GET through your PHP worker for every download. A missing file makes `size()` throw `UnableToRetrieveMetadata`, so check `fileExists()` first. Streaming through the app keeps authorization on every request; for large or frequent downloads, redirecting to a `temporaryUrl()` offloads the bytes.

code

php · 24 lines
php
<?php

namespace App\Http\Controllers;

use App\Models\MedicalDocument;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Storage;
use Symfony\Component\HttpFoundation\StreamedResponse;

class DocumentViewController extends Controller
{
    public function __invoke(Request $request, MedicalDocument $document): StreamedResponse
    {
        abort_unless($request->user()->can('view', $document), 403);

        $disk = Storage::disk('documents');
        abort_unless($disk->fileExists($document->path), 404);

        return $disk->response($document->path, 'lab-report.pdf', [
            'Content-Type' => $document->mime_type,
            'Content-Length' => $document->size_bytes,
        ]);
    }
}

go deeper

for a junior

Recall that download() makes the browser save the file and response() lets it display inline, and that both stream from the disk.

for a middle

Explain how response() builds headers from mimeType() and size(), streams the body with readStream(), and why a missing file throws.

for a senior

Judge when to relay bytes through PHP versus redirecting to a temporary URL, and cut S3 round trips by passing known headers.

for a principal

Balance per-request authorization and auditing against worker capacity and bandwidth when choosing the download architecture.

## Two helpers, one implementation In Laravel's `FilesystemAdapter`, `download()` is a one-liner: ```php return $this->response($path, $name, $headers, 'attachment'); ``` So the whole story is in `response()`. Its signature is `response($path, $name = null, array $headers = [], $disposition = 'inline')`, and it returns a `Symfony\Component\HttpFoundation\StreamedResponse`, a response whose body is produced by a callback when the response is sent rather than held in memory. | | `response()` | `download()` | |---|---|---| | Default `Content-Disposition` | `inline` | `attachment` | | Browser behaviour | displays PDFs and images in the tab | saves the file | | Filename | `$name` or `basename($path)` | same | | Return type | `StreamedResponse` | `StreamedResponse` | ## What happens when you call it 1. **Content-Type**: if `$headers` has none, Laravel calls `mimeType($path)`. 2. **Content-Length**: if absent, Laravel calls `size($path)`. 3. **Content-Disposition**: if absent, Laravel builds one from the disposition, the filename and an ASCII fallback name (via `Str::ascii`, with `%` removed) for older clients. 4. **Body**: a callback is registered that, when the response is sent, opens `readStream($path)`, copies it to the output with `fpassthru()`, and closes it. Because of step 4, memory stays flat even for large files on the local driver: bytes flow from the disk to the client in chunks. ## The cost on an S3 disk On a remote disk each step becomes network traffic: - `mimeType()` and `size()` are **metadata lookups** against the bucket, made before the response even exists. Pass `Content-Type` and `Content-Length` yourself when you already store them, for example in the document's database row. - The body is a **GET for the whole object**, relayed through your PHP worker. That worker is busy for as long as the client takes to download. - Laravel's `s3` driver passes `stream_reads` as `false` unless the disk config sets `'stream_reads' => true`; with it on, the object is requested as an HTTP stream, which matters for large files. For a clinic portal serving one-page lab reports, this is fine. For hundreds of megabytes of imaging data, relaying every byte through PHP ties up workers, and redirecting to a short-lived `temporaryUrl()` hands the transfer to the storage service. ## Failure behaviour `response()` and `download()` do not check existence. For a missing file, `mimeType()` returns `false` on a disk with `throw` off, then `size()` throws `League\Flysystem\UnableToRetrieveMetadata`, which becomes a 500 error. Guard it: ```php abort_unless(Storage::disk('documents')->fileExists($path), 404); ``` ## Choosing between streaming and redirecting - **Stream with `response()`/`download()`** when every access must be authorized and logged at the moment of download, the file is small, or the storage location must stay hidden. - **Redirect to `temporaryUrl()`** when files are large or popular and a short window of bearer access is acceptable. - **Use `response()` for viewing**, so PDFs open in the browser's viewer; **use `download()` for saving**, so the browser offers a file dialog with your chosen name. ## Streaming from a job or a Blade page A `StreamedResponse` only makes sense as the return value of a route or controller: its callback runs while the framework sends the response. Two consequences: - Returning it from a queued job or storing it for later does nothing useful; jobs that need file contents should use `readStream()` directly. - Middleware that inspects or rewrites the response body cannot see the content, because it does not exist until the callback runs. Middleware that only adds headers works as usual. For HTTP caching, remember that browsers and proxies may keep private documents. Adding `'Cache-Control' => 'private, no-store'` to `$headers` stops shared caches from holding a patient's report; the local disk's signed route already sends `no-store` for this reason. ## Useful extras - `Storage::serve($request, $path)` calls `response()` unless a `serveUsing()` callback replaces it; this is what the local disk's signed route uses. - Passing a full `Content-Disposition` in `$headers` skips Laravel's generated one entirely. - The `$name` argument controls what the user sees, so a stored path like `documents/7f3a...pdf` can download as `lab-report-2026-09.pdf`.

  • Why does Storage::download() on a missing file produce a 500 instead of a 404?
    `download()` calls `response()`, which looks up `mimeType()` (wrapped, returns `false`) and then `size()`, which is not wrapped and throws `UnableToRetrieveMetadata`. Nothing converts that to a 404, so check `fileExists()` and `abort(404)` before building the response.
  • When is redirecting to temporaryUrl() better than streaming with download()?
    When files are large or requested often. Streaming relays every byte through a PHP worker that stays busy for the whole transfer; a redirect lets the storage service send the bytes. The trade-off is a short bearer window: whoever holds the link can use it until it expires.

saying these in an interview costs you the question

  • download() loads the whole file into memory before sending it
  • response() and download() return a 404 automatically for a missing file
  • download() sets Content-Disposition to inline so PDFs open in the browser
  • Streaming through the app costs nothing extra on S3 compared with a local disk
  • The name argument renames the stored file on the disk