---
title: "Remote URLs — Media Library for Laravel | Roundly"
description: "Fetch, vet or validate user-supplied URLs with the SSRF guard behind addFromUrl() — MediaLibrary::remote(), SafeRemoteUrl and retryable errors."
url: https://roundly-consulting.com/open-source/docs/media-library-for-laravel/remote-urls
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)

# Remote URLs

addFromUrl() fetches a user’s URL without letting it reach your own network. MediaLibrary::remote() exposes the same guard as a public API that stores nothing: fetch a page for indexing, check a URL a user typed, or judge one address. One guard and one hop loop serve both, so addFromUrl() and remote() refuse exactly the same destinations:

```php
use RoundlyConsulting\MediaLibrary\Facades\MediaLibrary;

$page = MediaLibrary::remote()->fetch($url);           // RemoteResponse (2xx only)
$page->body;          // string, at most the byte cap
$page->status;        // int
$page->contentType;   // ?string
$page->url;           // the URL the last redirect led to
$page->headers;       // array<string, list<string>>

MediaLibrary::remote()->fetch($url, maxBytes: 5_000_000, timeout: 15, maxRedirects: 3, accept: 'text/html, text/plain');

$vetted = MediaLibrary::remote()->vet($url);           // VettedUrl, or throws — fetches nothing
MediaLibrary::remote()->isPrivateAddress('10.0.0.1');  // true
```

## The remote() handle

| Method | Returns | Does |
| --- | --- | --- |
| `fetch($url, maxBytes:, timeout:, maxRedirects:, accept:)` | `RemoteResponse` | A 2xx answer, or a RemoteFetchFailed. Null options take media.remote.fetch\_max\_bytes, timeout (seconds, every hop together) and max\_redirects; accept is sent as the Accept header on every hop. |
| `vet($url)` | `VettedUrl` | The guard’s verdict without fetching. Throws RemoteFileRejected, or RemoteFileUnavailable when the host does not resolve; judges even with block\_private\_networks off. |
| `isPrivateAddress($address)` | `bool` | Whether no fetch may reach this address. Unparseable input counts as private; an IPv6 zone id is ignored. |

The manager has the same remote() handle, and both calls exist as actions:

```php
use RoundlyConsulting\MediaLibrary\Actions\FetchRemoteUrlAction;
use RoundlyConsulting\MediaLibrary\Actions\VetRemoteUrlAction;
use RoundlyConsulting\MediaLibrary\DataTransferObjects\FetchOptions;
use RoundlyConsulting\MediaLibrary\MediaLibraryManager;

public function __construct(private MediaLibraryManager $media) {}
$this->media->remote()->fetch($url, maxBytes: 1_000_000);

app(FetchRemoteUrlAction::class)->execute($url, new FetchOptions(maxBytes: 1_000_000, accept: 'text/html'));
app(VetRemoteUrlAction::class)->execute($url);
```

Under MediaLibrary::fake(), remote() stays real and is not recorded: nothing is stored, and its requests go through Laravel’s HTTP client, so Http::fake() answers them — see Testing for faking its DNS lookups too.

## Making the request yourself

vet() returns a VettedUrl: url (the canonical URL a fetch would request — scheme and host lowercased, a trailing dot and a default port dropped, the fragment cut), host, port and addresses (every vetted address; none for a host name trusted by media.remote.allowed\_private\_hosts). For a request of your own, pass httpOptions() to Laravel’s HTTP client — it pins the vetted addresses and keeps the request off the environment’s proxy, which would otherwise resolve the name again:

```php
$vetted = MediaLibrary::remote()->vet($url);

Http::withOptions($vetted->httpOptions())->withoutRedirecting()->get($vetted->url);
// ['proxy' => '', 'curl' => [CURLOPT_RESOLVE => ['example.com:443:93.184.216.34,[2606:…]']]]
```

Follow no redirect with it: a redirect is a new URL to vet. resolveEntry() is the bare CURLOPT\_RESOLVE entry (null when there is nothing to pin), which on its own does not keep a request off the proxy. httpOptions() throws RemoteFileRejected when there is something to pin and no ext-curl. fetch() does all of this for you.

## What every hop goes through

- Canonicalise — the guard parses the URL itself, because PHP, curl and browsers read some hosts differently. Refused: a control character anywhere, a scheme other than http/https, a missing authority, user info (even a bare @), a backslash, whitespace or % in the host (a percent-encoded host, an IPv6 zone id), a port outside 1–65535 or media.remote.allowed\_ports, numeric IPv4 spellings (2130706433, 0x7f.1, 0177.0.0.1, 127.1) and a backslash in the path, query or fragment. The request URL is rebuilt from the parts that passed.
- Internationalised hosts — a host with a non-ASCII character or a punycode (xn--) label goes through UTS #46 with ext-intl and is refused without it. A host IDNA maps to an address (８.８.８.８, 127。0。0。1) is refused, never normalised.
- Vet — localhost and \*.localhost are refused by name, an address literal is judged as it is, and a name is resolved once and every address must be public. media.remote.allowed\_private\_hosts exempts names (trusted as named, not resolved), addresses and ranges.
- Pin — curl’s CURLOPT\_RESOLVE gets every vetted address, so a second DNS answer can’t swap in an internal one and a dead first address fails over only to addresses the guard checked. The request skips HTTP\_PROXY / HTTPS\_PROXY, which would resolve the name itself.
- Request — without following redirects, on the remaining share of the one timeout budget. media.remote.headers go only to the first URL’s origin (scheme, host, port) unless forward\_headers\_on\_redirect is on, so an Authorization header meant for one host never reaches the host it redirects to.
- Read by status — a 1xx answer (103 Early Hints) is passed over. Only a 2xx body is read, through a byte cap counted after decoding, so a small gzip that inflates past it is stopped too. Any other answer stops once its headers are in: a 503 with a huge error page stays a retryable 503.
- Follow — a 301, 302, 303, 307 or 308 with a Location starts again at the first step, up to max\_redirects. A relative Location is resolved per RFC 3986; one with a backslash or a control character is refused.

With media.remote.block\_private\_networks off, canonicalising, vetting and pinning shrink to a scheme check: nothing is judged or pinned and the environment’s proxy applies. The headers rule, the time budget, the redirect limit and the byte cap still hold. Every limit is a media.remote.\* key — fetch\_max\_bytes, timeout, max\_redirects, allowed\_ports, headers, forward\_headers\_on\_redirect, block\_private\_networks and allowed\_private\_hosts (see Configuration).

## Errors

Both remote errors extend RemoteFetchFailed (a MediaLibraryException), which carries retryable(), status() (the HTTP status, when that is why), retryAfter() (seconds, from Retry-After) and reason():

| Exception | retryable() | When |
| --- | --- | --- |
| `RemoteFileRejected` | `false` | Terminal: an invalid URL, scheme or port, user info, a non-canonical host, a blocked destination; too many redirects; a body over the cap; no ext-curl to pin with; a status other than 2xx, 408, 429 and 5xx; a TLS certificate that does not verify. |
| `RemoteFileUnavailable` | `true` | Retryable: a 5xx, 408 or 429 (retryAfter() in seconds when the server sent Retry-After); a refused or reset connection; the time budget running out; a host that does not resolve. |

reason() returns a RemoteFetchReason — InvalidUrl, Scheme, Credentials, NonCanonicalHost, Blocked, Port, Unresolved, TooManyRedirects, TooLarge, CannotPin, Status, Tls, Connection or Timeout — so a queued job can tell trying again from giving up:

```php
use RoundlyConsulting\MediaLibrary\Exceptions\RemoteFileRejected;
use RoundlyConsulting\MediaLibrary\Exceptions\RemoteFileUnavailable;

try {
    $page = MediaLibrary::remote()->fetch($document->url, accept: 'text/html, text/plain');
} catch (RemoteFileUnavailable $e) {
    $this->release($e->retryAfter() ?? 60);   // a queued job tries again
} catch (RemoteFileRejected $e) {
    $document->fail($e->reason()->value);     // terminal: do not retry
}
```

Messages name the URL with all of its user info masked, and never an address it resolved to — that would map your internal network for whoever supplied the URL — so the transport exception is not chained either. addFromUrl() keeps its own contract: a refused URL or a host that does not resolve throws RemoteFileRejected, a non-2xx answer throws FileDoesNotExist and a transport failure surfaces as Laravel’s ConnectionException. FetchOptions throws InvalidArgumentException for a cap below 1, negative redirects or a blank accept.

## Validation — SafeRemoteUrl

Check a URL when a user hands it in, before anything stores it:

```php
use RoundlyConsulting\MediaLibrary\Rules\SafeRemoteUrl;

'documents.*.origin' => ['required', 'string', 'max:2048', new SafeRemoteUrl],
```

It passes when vet() accepts the URL and fails with one translated line for every refusal, media::validation.remote\_url. A different message for a private host and an unknown one would tell a user which internal names exist, so the rule never makes that distinction — call vet() for the exact reason. A non-string value fails the same way, and the fetch vets every hop again, since DNS can change in between. The English and Slovak messages load without publishing; publish them to edit:

```bash
php artisan vendor:publish --tag="media-translations"
```

## Blocked networks

IPv4: 0.0.0.0/8, 10.0.0.0/8, 100.64.0.0/10 (carrier-grade NAT), 127.0.0.0/8, 168.63.129.16/32 (Azure WireServer), 169.254.0.0/16 (link-local, cloud metadata), 172.16.0.0/12, 192.0.0.0/24, 192.0.2.0/24, 192.88.99.0/24, 192.168.0.0/16, 198.18.0.0/15, 198.51.100.0/24, 203.0.113.0/24, 224.0.0.0/4 and 240.0.0.0/4. IPv6: 64:ff9b:1::/48, 100::/64, 2001::/23 (Teredo included), 2001:db8::/32, 3fff::/20, 5f00::/16, fc00::/7 (AWS metadata at fd00:ec2::254), fe80::/10, fec0::/10 and ff00::/8.

An IPv6 address carrying an IPv4 one — IPv4-mapped, IPv4-compatible (so :: and ::1 are blocked), NAT64 (64:ff9b::/96), IPv4-translated and 6to4 (2002::/16) — is judged by the IPv4 address it carries, so DNS64 clusters still reach public hosts while 64:ff9b::7f00:1 stays blocked.

## Known limits

- PHP’s resolver has no timeout: a host whose DNS hangs holds the call until the system resolver gives up. The lookup counts against the time budget but can’t be cut short by it.
- IPv6 addresses come from AAAA records, so a hosts-file IPv6 entry is not seen. That is the safe direction: an address the guard did not see is never dialled.
- Http::fake() skips the socket, so your tests prove the guard’s refusals, not the pin. Pair it with MediaLibrary::fakeDns() so the lookups are deterministic too.

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