Serving Files
Three ways to send a stored file: a public symlink, a controller that checks first, or a signed temporary URL.
Storage::disk('local')->temporaryUrl(string $path, DateTimeInterface $expiration, array $options = []): stringGetting a stored file to a browser is a choice between three routes. They differ in who checks permission and whether PHP touches the bytes.
The public disk and its symlink
Files on the public disk live in storage/app/public. php artisan storage:link creates the link listed under the links key of config/filesystems.php: public/storage pointing at that directory. Nginx or Apache then serves the file itself and PHP is never started. Storage::disk('public')->url($path) builds the address from the disk's url setting. No check is possible here, so anything on this disk is readable by anyone who learns the path.
Through a controller
For a file that needs a check, route it to a controller, run the check, then return Storage::disk('local')->download($path, $name). That is a StreamedResponse carrying Content-Disposition: attachment; response($path, $name) is the same object with inline. Every byte passes through PHP, so one worker is tied up for the whole transfer.
Temporary signed URLs
temporaryUrl() returns an address that stops working. On s3 it is a presigned request to the bucket. On a local disk with 'serve' => true, the framework registers a route named storage.local and signs it, and a private served disk demands a valid relative signature before it reads anything. Either way the bytes skip your controller.
Example
<?php
// app/Http/Controllers/InvoiceDownloadController.php
declare(strict_types=1);
namespace App\Http\Controllers;
use App\Models\Invoice;
use Illuminate\Support\Facades\Gate;
use Illuminate\Support\Facades\Storage;
use Symfony\Component\HttpFoundation\StreamedResponse;
final class InvoiceDownloadController extends Controller
{
// Route model binding resolves {invoice} to the model before this
// method runs. Gate::authorize() then calls the InvoicePolicy's view()
// method and throws AuthorizationException, which renders as 403, so the
// read below only happens for a request that passed the check.
//
// $this->authorize() is the shorthand you will see in older code. It
// comes from the AuthorizesRequests trait, which Laravel 11 removed from
// the generated base controller, so on 13 it only works if you add
// Illuminate\Foundation\Auth\Access\AuthorizesRequests back yourself.
public function __invoke(Invoice $invoice): StreamedResponse
{
Gate::authorize('view', $invoice);
abort_unless(Storage::disk('invoices')->exists($invoice->path), 404);
// download() is response() with 'attachment' as the disposition:
// return Storage::disk('invoices')->response(
// $invoice->path, $invoice->filename, [], 'attachment'
// );
//
// The second argument is the filename the browser saves as. This is
// the one place the client's original name is useful again, and it is
// safe here because it goes into a header, not into a path.
return Storage::disk('invoices')->download(
$invoice->path,
$invoice->filename,
['Cache-Control' => 'no-store'],
);
}
}
// routes/web.php
use App\Http\Controllers\InvoiceDownloadController;
use Illuminate\Support\Facades\Route;
Route::get('/invoices/{invoice}/download', InvoiceDownloadController::class)
->middleware('auth')
->name('invoices.download');When to use it
- Letting a customer download an invoice only after a policy confirms it is theirs.
- Emailing a link to a report that stops working after an hour.
- Serving product photos straight from Nginx so PHP handles no image traffic.
- Offering a download under the user's original filename while the stored name stays hashed.
More examples
The expiry is an instant, not a duration
Worth running once if you have ever passed 300 to temporaryUrl() and got a link valid until 1970. The method signature is temporaryUrl(string $path, DateTimeInterface $expiration), so an integer is not an acceptable second argument.
<?php
declare(strict_types=1);
use Illuminate\Support\Carbon;
// temporaryUrl() takes the moment the link stops working, not a number of
// seconds. A fixed base is used here so the output is the same every run;
// in an application you would write now()->addMinutes(5).
$issuedAt = Carbon::parse('2026-03-01 09:00:00', 'UTC');
$expiresAt = $issuedAt->copy()->addMinutes(5);
echo $expiresAt->toDateTimeString(), PHP_EOL;
// The signature carries this as an "expires" value in the query string, so
// the server needs no state to know the link is stale.
echo $expiresAt->getTimestamp(), PHP_EOL;
// Carbon 3 returns a signed difference, so order the operands deliberately.
echo (int) $issuedAt->diffInSeconds($expiresAt), PHP_EOL;
// Output:
// 2026-03-01 09:05:00
// 1772355900
// 300Signed URLs, on a bucket and on a local disk
This is the pattern for a download link you can email or put in a response body without an authenticated session. Reach for it when the alternative is streaming a large file through a PHP worker for every request.
<?php
declare(strict_types=1);
use Illuminate\Support\Facades\Storage;
use Illuminate\Support\Facades\URL;
// On s3 this is a presigned GetObject request. The bytes come from AWS and
// never enter the PHP process.
$url = Storage::disk('s3')->temporaryUrl($invoice->path, now()->addMinutes(5));
// On a local disk it works only when that disk has 'serve' => true. Check
// before you call, because the method throws RuntimeException otherwise:
// "This driver does not support creating temporary URLs."
if (Storage::disk('local')->providesTemporaryUrls()) {
$url = Storage::disk('local')->temporaryUrl($path, now()->addMinutes(5));
}
// Signing a route of your own is the same mechanism. Mind the argument
// order, which differs between the two helpers:
//
// URL::signedRoute(string $name, $parameters = [], $expiration = null, bool $absolute = true)
// URL::temporarySignedRoute(string $name, $expiration, $parameters = [], bool $absolute = true)
//
// Shorthand, expiry second from last:
$share = URL::temporarySignedRoute('invoices.share', now()->addHour(), ['invoice' => $invoice->id]);
// Longhand, the call it forwards to, expiry third:
$share = URL::signedRoute('invoices.share', ['invoice' => $invoice->id], now()->addHour());
// routes/web.php -- the 'signed' middleware alias validates the signature
// and throws InvalidSignatureException, which renders as 403.
//
// Route::get('/invoices/{invoice}/share', ShareController::class)
// ->middleware('signed')
// ->name('invoices.share');
//
// Use 'signed:relative' when the URL was signed with absolute: false, which
// is what a load balancer rewriting the host requires.What the public symlink actually does
Use the public disk for files with no access rules at all. The important detail is that url() reads the disk configuration while asset() does not, so writing url() from the start is what lets you move the disk later without touching a template.
<?php
// Nothing on the public disk is protected, so only put files there that are
// genuinely public: avatars, logos, product photos.
use Illuminate\Support\Facades\Storage;
$path = $request->file('photo')->store('photos', 'public');
// => "photos/8xKq...Yw3.jpg", relative to storage/app/public
$url = Storage::disk('public')->url($path);
// => "https://example.test/storage/photos/8xKq...Yw3.jpg"
// built from the disk's 'url' key, which the shipped config sets to
// rtrim(env('APP_URL'), '/').'/storage'
// resources/views/profile.blade.php
// <img src="{{ Storage::disk('public')->url($user->avatar_path) }}" alt="">
//
// The asset() helper is NOT equivalent: asset('storage/'.$path) hard-codes
// the /storage prefix and ignores the disk's url setting, so it breaks the
// day the public disk moves to a bucket.
// On a server built fresh by a deploy, run this once per release:
// php artisan storage:link
//
// Options: --relative writes a relative link, which survives a build
// directory being moved; --force recreates a link that already exists,
// which is what you need after a deploy copied the link as a real folder.
Discussion