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

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:

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

MethodReturnsDoes
fetch($url, maxBytes:, timeout:, maxRedirects:, accept:)RemoteResponseA 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)VettedUrlThe guard’s verdict without fetching. Throws RemoteFileRejected, or RemoteFileUnavailable when the host does not resolve; judges even with block_private_networks off.
isPrivateAddress($address)boolWhether 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:

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:

$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 (8.8.8.8, 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():

Exceptionretryable()When
RemoteFileRejectedfalseTerminal: 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.
RemoteFileUnavailabletrueRetryable: 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:

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:

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:

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

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.