URLs, streaming & downloads
Public media exposes a direct disk URL — through the owner handle or on the Media itself:
MediaLibrary::for($user)->url('avatar'); // first media's URL, the fallback, or ''
MediaLibrary::for($user)->url('avatar', 'thumb'); // first media's variant URL
$media->getUrl(); // original (public media)
$media->getUrl('thumb'); // a named variant (on the variants disk)Private & temporary URLs
Private media has no public URL, so use a temporary URL: it presigns natively on disks that support it (S3 and any driver implementing temporaryUrl()) or falls back to a Laravel signed streaming route. When browsers reach the object store through a different origin than the app does, use the package’s presigned-s3 disk driver (below) so the URL is signed for the public origin. Without an expiry, the lifetime defaults to temporary_url_default_lifetime. Large files stream efficiently, including resumable partial downloads:
// Private media has no public URL — getUrl() throws. Use a temporary URL:
MediaLibrary::for($user)->temporaryUrl('avatar', expiry: now()->addMinutes(10));
$media->getTemporaryUrl(now()->addMinutes(5));
$media->getTemporaryUrl(now()->addMinutes(5), 'thumb');
// presigned on S3-like disks, or a Laravel signed streaming route on local/cold disks
return $media->toResponse($request); // stream inline (safe types), range-aware
return $media->toDownloadResponse('invoice.pdf'); // as an attachmentStreaming route
The package registers GET {prefix}/{media}/{variant?}, named media.stream. {media} binds by UUID, and the route runs the configured middleware plus Laravel's signed middleware, which is always appended; ?download=1 in the signed URL serves an attachment. The route never hands out anything without a valid signature (or, on presigning disks, a native presigned URL):
'stream' => [
'enabled' => true, // register the signed streaming route
'route_prefix' => 'media', // URI prefix
'middleware' => ['web'], // Laravel's 'signed' is always appended
],What is served inline
Only types a browser renders without running anything — raster images, audio, video, PDF and plain text — are served inline. HTML, SVG, XML and every other type are sent as an attachment, and every response carries X-Content-Type-Options: nosniff and a sandboxing Content-Security-Policy, so a stored file can never run script on your application’s origin. A variant is served with its own format’s type. An unparseable or multi-range Range header is ignored (the whole file, 200); a range past the end of the file gets 416.
Private media needs a private disk
->private() / withVisibility('private') sets the file’s visibility — an S3 object ACL, or file mode 0600 on a local disk. It doesn’t stop a web server that serves the disk directly: the default public disk is symlinked under public/storage, so a private file stored there can still be fetched from /storage/{uuid}/{file} wherever the web server runs as the PHP user — and the uuid appears in every signed URL. Keep private buckets on a disk the web server doesn’t serve, such as Laravel’s local disk (storage/app/private) or a private S3 bucket:
// Keep private buckets on a disk the web server does not serve.
$this->addMediaBucket('invoices')
->useDisk('local') // storage/app/private — not symlinked under public/storage
->private();A separate presign endpoint — the presigned-s3 disk
A Kubernetes deployment often stores media in an in-cluster object store (http://rustfs:9000) that browsers can’t reach; they reach the same store through a public origin (https://media.example.com, an ingress in front of it). Presigned URLs must name the public origin while every read and write still goes in-cluster. Laravel’s s3 driver can’t do that: SigV4 signs the host, and its temporary_url option re-hosts the URL after signing, so the object store refuses every link. The package registers an opt-in presigned-s3 disk driver instead — a disk opts in by naming it, and every s3 key still applies:
// config/filesystems.php
'media' => [
'driver' => 'presigned-s3', // instead of 's3'
'key' => env('AWS_ACCESS_KEY_ID'),
'secret' => env('AWS_SECRET_ACCESS_KEY'),
'region' => env('AWS_DEFAULT_REGION'),
'bucket' => env('AWS_BUCKET'),
'endpoint' => env('AWS_ENDPOINT'), // in-cluster: http://rustfs:9000
'use_path_style_endpoint' => true,
'presign_endpoint' => env('AWS_PRESIGN_ENDPOINT'), // public: https://media.example.com
'visibility' => 'private',
],| Disk key | Default | Meaning |
|---|---|---|
presign_endpoint | not set (off) | The public origin presigned URLs are signed for: an absolute http or https URL with a host and nothing else. User info, a query, a fragment or a path other than / is refused, because each would be signed into every URL. http is allowed for local development; blank means off. |
presign_use_path_style_endpoint | inherits use_path_style_endpoint | Path style for the public origin only, when it routes differently from the in-cluster store. A boolean (true/false, 1/0, on/off, yes/no); anything else throws. |
- Without presign_endpoint the disk is Laravel’s stock S3 adapter, byte for byte — temporary_url included, so a host-preserving proxy setup keeps working.
- With it, reads, writes, exists(), delete() and the rest go to endpoint unchanged, while temporaryUrl() and temporaryUploadUrl() are signed for the public origin by a second client. That client shares the disk’s credentials (session tokens, IRSA and web identity included), region and client options, and never sends a request.
- url() returns the disk’s url + path when the disk sets url (a CDN in front of public objects). Without one it throws DiskHasNoPublicUrl rather than build the URL from the in-cluster endpoint — keep media on such a disk private and use temporary URLs.
- temporary_url next to presign_endpoint is refused when the disk is built: one signs for the public host, the other re-hosts after signing. SigV4 caps a presigned URL at seven days.
- The S3 stack stays yours: like Laravel’s s3 driver, the disk needs league/flysystem-aws-s3-v3, which the package only suggests. Without it, or with an unusable presign key, building the disk throws InvalidConfigurationException naming filesystems.disks.<disk>.<key> — never the value — on first use of the disk, never at boot.
Through the package nothing changes: getTemporaryUrl() and MediaLibrary::for($model)->temporaryUrl() call the disk’s temporaryUrl(), so media on such a disk gets public-origin URLs. The ingress must forward requests to the object store with the public Host header unchanged, because the store checks the signature against the host it receives; MinIO and RustFS in virtual-host mode also need their domain set to the public host.
Show your open-source love
This package is free and MIT-licensed. If it saves you time, a one-off donation or a Patreon membership keeps it maintained, tested and documented.
More ways to support, including cryptoBy donating, you agree to our donation terms.
Want this built into your product?
We integrate our packages into custom Laravel and AI builds. Tell us what you're working on and we'll reply within 48 hours.