---
title: "URLs, streaming & downloads — Media Library for Laravel | Roundly"
description: "Public URLs, presigned temporary URLs for private media — signed for a separate public origin if needed — and a range-aware streaming route."
url: https://roundly-consulting.com/open-source/docs/media-library-for-laravel/urls-streaming
language: en
---

[All packages](https://roundly-consulting.com/open-source.md)

[Media Library for Laravel](https://roundly-consulting.com/open-source/docs/media-library-for-laravel.md)

# URLs, streaming & downloads

Public media exposes a direct disk URL — through the owner handle or on the Media itself:

```php
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:

```php
// 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
```

## 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):

```php
'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:

```php
// 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:

```php
// 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 crypto](https://roundly-consulting.com/support-us.md)

By donating, you agree to our [donation terms](https://roundly-consulting.com/donation-terms.md).

[Support our open source work (opens in a new tab)](https://donate.stripe.com/dRmeVe8FX5PF1Qd9pXcEw00) [Join us on Patreon (opens in a new tab)](https://www.patreon.com/cw/roundly)

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

[Get a quote in 48 hours](https://roundly-consulting.com/contact.md) [Browse all packages](https://roundly-consulting.com/open-source/docs/media-library-for-laravel.md)
