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

Use in your own package

Packages that send requests to URLs their own users supply — webhook senders, importers, link previews — build on Safe Fetch the same way.

A policy of your own, on top of the host’s

Start from SafeFetch::policy() so the host’s blocks — blocked hosts and ranges, its own app hosts — still apply, and drop the host’s carve-outs: a name it trusts to be private (minio.internal), a range it opened for local development (127.0.0.1/32) and a host-wide allowlist were set up for the host’s own fetches, not for URLs your users type in:

$policy = SafeFetch::policy()
    ->withoutHostAllowlist()->withoutPrivateHosts()->withoutAllowedRanges()
    ->blockAppHosts()->appHosts($appHosts)
    ->blockHosts($blockedHosts)->blockRanges($blockedRanges)->allowRanges($allowedRanges)
    ->ports($ports)->maxResponseBytes(4096)
    ->redirects(RedirectPolicy::methodPreserving($max, forwardHeaders: ['webhook-id', 'webhook-timestamp', 'webhook-signature']));
$policy = $allowedHosts === [] ? $policy : $policy->allowHosts($allowedHosts);
  • blockAppHosts() turns the app-host block on whatever the host configured (blockAppHosts(false) turns it off).
  • allowHosts() replaces the allowlist and refuses an empty list — branch on your own setting as above rather than passing a list that may be empty.
  • RedirectPolicy::methodPreserving($max, forwardHeaders: [...]) follows 307/308 only and sends the named headers to another origin a redirect leads to; every other header and every credential stays with the first origin.

Send through the manager, throttle first

Inject SafeFetchManager (or use the facade) and call guard() — never the Http::safe() macro, which another package could replace. Add a throttle before the guard so it runs once per call, before any DNS lookup ($throttle is any Guzzle middleware):

$response = $safeFetch->guard(
    Http::withMiddleware($throttle)->connectTimeout(5)->timeout(10)->withHeaders($headers),
    $policy,
)->withBody($body, 'application/json')->post($url);

Retries stop at a refusal

$response = $safeFetch->guard(Http::retry(3, 100, fn (Throwable $e) => ! $e instanceof SafeFetchException), $policy)
    ->post($url, $payload);

Terminal or retryable

try {
    $response = $safeFetch->guard(Http::timeout(10), $policy)->post($url, $payload);
} catch (DestinationRejected $e) {
    $outcome = ['retry' => false, 'reason' => $e->reason()->value, 'rule' => $e->blockedBy()];   // never retry
} catch (DestinationUnavailable $e) {
    $outcome = ['retry' => true, 'reason' => $e->reason()->value];                              // unresolved, out of time
}

DestinationRejected is terminal — store reason() and blockedBy() for operators, never show the difference to the person who submitted the URL. A transport failure of a guarded request is Laravel’s own ConnectionException, whose message names the URL — do not echo it.

A capped answer and where it came from

$response = $safeFetch->guard(Http::timeout(10), $policy->maxResponseBytes(4096))->post($url, $payload);

$excerpt = $response->body();                                    // at most 4 096 bytes
$cut = $safeFetch->truncated($response);                         // true when the body was cut
$remoteAddress = $response->handlerStats()['primary_ip'] ?? null; // the vetted address it connected to

Save time: every rule except the lookup

When a URL is stored rather than called (an endpoint a user registers), judge it without waiting on DNS:

$request->validate(['endpoint' => ['required', 'string', SafeUrl::withoutLookup($policy)]]);
$stored = $safeFetch->precheck($request->input('endpoint'), $policy)->toString();

The guard judges the URL in full on every request; the testing recipes for a package are in Testing.

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.