In Laravel, when do you use Storage::url() versus Storage::temporaryUrl(), for example for private medical documents that should download for ten minutes?
answer
- one is permanent, one expires
- url(): no signature, no expiry
- s3 temporaryUrl: presigned GetObject
- local disk serve => true signs a route
- unsupported driver: RuntimeException
basics
~10 sStorage::url() builds a permanent, unsigned link that works only for publicly readable files. Storage::temporaryUrl($path, now()->addMinutes(10)) builds a signed link that expires, which suits private medical documents once the user has been authorized.
solid answer
~40 s`Storage::url()` returns a plain address: `/storage/...` or the disk's `url` base on the local driver, and the object URL (or `AWS_URL` base) on S3. It carries no signature and never expires, so it only works when the file itself is public. `Storage::temporaryUrl($path, $expiresAt, $options)` returns a link that stops working at `$expiresAt`: on the `s3` driver it is a presigned `GetObject` request, and `$options` such as `ResponseContentDisposition` go into that request; on a local disk with `'serve' => true` it is a signed route named `storage.{disk}` that Laravel registers itself. Drivers without either throw `RuntimeException` unless you register `buildTemporaryUrlsUsing()`. For medical documents you keep the files private, authorize the user in a controller, then redirect to a ten-minute temporary URL.
code
php · 24 lines<?php
namespace App\Http\Controllers;
use App\Models\MedicalDocument;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Storage;
class DocumentDownloadController extends Controller
{
public function __invoke(Request $request, MedicalDocument $document): RedirectResponse
{
abort_unless($request->user()->can('view', $document), 403);
$url = Storage::disk('s3')->temporaryUrl(
$document->path,
now()->addMinutes(10),
['ResponseContentDisposition' => 'attachment; filename="report.pdf"'],
);
return redirect()->away($url);
}
}go deeper
Recall that url() gives a permanent public link and temporaryUrl() gives a signed link that expires at the date you pass.
Explain how temporaryUrl() is produced on S3 versus a local disk with serve enabled, and which drivers throw RuntimeException.
Design the private-download flow: authorize in a controller, issue a short-lived link, force download headers, and treat the link as a bearer token.
Weigh short-lived links against streaming through the app for sensitive data, balancing auditability, revocation and server load.
## Two kinds of link A clinic portal stores patients' scanned referral letters and lab reports. Some files are public (the clinic's logo, a printable leaflet); most are private and must only reach the patient or their doctor. Laravel's Storage API offers two link builders for these cases: | | `url($path)` | `temporaryUrl($path, $expiration, $options = [])` | |---|---|---| | Signed | no | yes | | Expires | never | at `$expiration` | | Works for private files | no | yes | | Local driver | `/storage/{path}` or the disk's `url` base | signed route `storage.{disk}`, only with `'serve' => true` | | S3 driver | object URL, or `AWS_URL` base + key | presigned `GetObject` URL | | Other drivers | FTP/SFTP join the `url` key (or return the bare path); others throw | `RuntimeException` unless a custom builder is set | ## url(): a plain address `url()` does no permission check and adds no signature. On the local driver it prefixes `/storage/`, which only resolves because `php artisan storage:link` exposed `storage/app/public` under `public/storage`. On S3 it asks the client for the object's URL (or joins the disk's `url` base with the key). If the object is private, the storage service refuses the request; Laravel does not know or check. Use it for files that are public by design: thumbnails on the `public` disk, or S3 objects written with `'public'` visibility or served from a public CDN path. ## temporaryUrl() on S3 On the `s3` driver, Laravel builds a `GetObject` command for the bucket and key, merges your `$options` into it, and asks the AWS SDK for a **presigned request** valid until `$expiration`. The returned string embeds the signature and expiry; anyone holding it can download the object until then, without further checks. Useful options: - `ResponseContentDisposition` — `'attachment; filename="lab-report.pdf"'` forces a download with a friendly name; - `ResponseContentType` — overrides the type the browser sees. A `temporary_url` key on the disk replaces the scheme, host and port of the signed URL, for when the API host differs from the host browsers should use. ## temporaryUrl() on a local disk The Laravel 13 skeleton's `local` disk has `'serve' => true`. For every local disk with that flag, the filesystem service provider registers a `GET {url-path}/{path}` route named `storage.{disk}` (`/storage/{path}` when the disk has no `url` key). `temporaryUrl()` then returns a signed URL for that route. When it is requested: 1. The signature and expiry are checked; a bad or expired link returns 404 in production and 403 elsewhere. 2. A missing file returns 404. 3. The file is streamed with `Cache-Control: no-store` and a sandboxing `Content-Security-Policy`. The `public` disk in the skeleton has no `serve` key, so `temporaryUrl()` on it throws `RuntimeException: This driver does not support creating temporary URLs.` The same happens on FTP or SFTP disks unless you register `Storage::disk(...)->buildTemporaryUrlsUsing(...)`, for example to return a `URL::temporarySignedRoute()` to your own download controller. ## The ten-minute medical-document flow 1. Store scans on a private disk; never on `public`. 2. Route downloads through a controller that loads the document and authorizes the current user against it. 3. Only then call `temporaryUrl($document->path, now()->addMinutes(10), [...])` and redirect to it. 4. Log the access if the domain requires an audit trail; the storage service will not tell you who used the link. The link is a **bearer token** for ten minutes: authorization happens when it is issued, not when it is used. Keep expiries short and never put these URLs in cached pages or emails that outlive them. ## Visibility decides whether url() can work **Visibility** is Flysystem's portable name for file permissions: `public` or `private`. It is what makes a plain `url()` succeed or fail: - On the local driver, only files under the linked `storage/app/public` folder are reachable, whatever their permission bits. - On S3, the skeleton's disk sets no `visibility`, so objects are written private; `put($path, $contents, 'public')` or `setVisibility($path, 'public')` marks one public, and `getVisibility($path)` reports the current value. - Keep medical files private for their whole life. Flipping a document to public "just for a moment" leaves a permanent, unsigned address that nobody can revoke by expiry. Some buckets are configured to reject per-object public permissions altogether; in that case `url()` only works through a public CDN or bucket policy, and `temporaryUrl()` is the normal way to share a file. ## Common mistakes - Calling `url()` on a private S3 object and wondering why the browser gets an access error. - Generating a temporary URL before checking that the user may see the document. - Expecting `temporaryUrl()` to work on the `public` disk or on an FTP disk out of the box.
- How does temporaryUrl() work on the skeleton's local disk when there is no S3 to presign?The local disk has `'serve' => true`, so the filesystem service provider registers a signed `storage.local` route. `temporaryUrl()` returns a temporary signed URL for that route; the route checks the relative signature and expiry, returns 404 in production for a bad link, and streams the file with no-store caching headers.
- How would you give an FTP disk temporary URLs?Call `Storage::disk('ftp')->buildTemporaryUrlsUsing(fn ($path, $expiration, $options) => URL::temporarySignedRoute('files.download', $expiration, ['path' => $path]))` in a service provider's `boot` method, and write the `files.download` route to stream the file. Without a builder, `temporaryUrl()` throws `RuntimeException`.
saying these in an interview costs you the question
- Storage::url() on S3 returns a signed link that expires after an hour
- A temporary URL rechecks the user's permissions every time it is opened
- temporaryUrl() works on every disk, including the public disk
- Private files can be shared safely with url() as long as the path is hard to guess
- The expiration argument is a number of minutes rather than a date