NewWe open-sourced 50+ Laravel packages
Custom AI apps, agents and automation — Roundly ConsultingRoundly
All packages
Media Library for Laravel

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 attachment

Response headers on temporary URLs

A presigned URL is answered by the storage origin, not by the package. Without instructions an S3 bucket serves an object with the type it was stored under and no Content-Disposition, so a stored HTML or SVG file renders inline — and runs — on the storage origin. Ask the origin for the streaming route’s rules instead:

use RoundlyConsulting\MediaLibrary\DataTransferObjects\TemporaryUrlOptions;
use RoundlyConsulting\MediaLibrary\Enums\ContentDisposition;
use RoundlyConsulting\MediaLibrary\Facades\MediaLibrary;

MediaLibrary::for($user)->temporaryUrl('documents', options: TemporaryUrlOptions::download());

$media->getTemporaryUrlWithOptions(now()->addMinutes(10), new TemporaryUrlOptions); // the rule below
$media->getTemporaryDownloadUrl(now()->addMinutes(10));                           // always a download
$media->getTemporaryDownloadUrl(now()->addMinutes(10), 'thumb', 'avatar.webp');  // variant, own name

$media->getTemporaryUrlWithOptions(now()->addMinutes(10), new TemporaryUrlOptions(
    disposition: ContentDisposition::Inline,   // null = the rule; inline is only honoured for safe types
    fileName: 'invoice-2026-01.pdf',           // null = the served file's name; reduced to one safe segment
    contentType: 'application/pdf',            // null = the served file's sniffed type
));

Or switch it on for every getTemporaryUrl() with media.temporary_url_response_headers — off by default so existing URLs don’t change; the next major release turns it on. Media adopted in place from a direct upload always get it, because their stored type is the browser’s.

  • The rule mirrors the streaming route: inline only for a type a browser renders without running anything (raster images, audio/*, video/*, application/pdf, text/plain), attachment for everything else. An explicit ContentDisposition::Inline on any other type — judged by the content type the response will carry, an override included — is still an attachment. The file name goes out RFC 6266-style: an ASCII filename= plus a UTF-8 filename*= when it is not plain ASCII.
  • S3 and S3-compatible disks (Laravel’s s3 driver, the package’s presigned-s3) sign them into the URL as response-content-disposition and response-content-type. S3 cannot add X-Content-Type-Options or a Content-Security-Policy per URL — set those on the bucket or the CDN in front of it.
  • A disk with a buildTemporaryUrlsUsing() callback receives the same two keys in its options; honouring them is up to the callback.
  • The signed streaming route already applies every rule (plus nosniff and the sandbox CSP). An asked-for attachment becomes ?download=1 and a file name ?name=, both part of the signature; a content type override does not apply there — the route always sends the file’s own type.
  • Laravel’s local serve route (a local disk with serve => true) ignores the options and answers with a sandboxing CSP of its own.
  • A custom media.url_generator must implement GeneratesTemporaryUrlsWithOptions for these calls (both built-in generators do); otherwise they throw TemporaryUrlNotSupported, while plain getTemporaryUrl() keeps working. TemporaryUrlOptions throws InvalidArgumentException for a content type that is not a type/subtype media type and for a blank file name or one with a control character.

Streaming 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 keyDefaultMeaning
presign_endpointnot 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_endpointinherits use_path_style_endpointPath 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 crypto

By 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.