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

Third-party clients

Http::safe() guards a request you build. A library that sends requests itself needs one of these instead:

The library…UseGuarded
takes a ready Guzzle client, or a PSR-18 client (Guzzle’s Client is both)SafeFetch::guzzleClient(array $config = [], ?DestinationPolicy $policy = null)yes
builds its own Guzzle client and lets you pass the handlerSafeFetch::handlerStack(?DestinationPolicy $policy = null)yes
sends through Laravel’s Http facade inside its own codeno hook — subclass what builds the request, or fetch the URL yourself (see the per-library notes)only where you swap the call

Both methods vet, pin and check every hop exactly as Http::safe() does: the same guard middleware, the same curl handler the guard owns, redirects followed only under the policy, the primary_ip post-check and the per-hop cookie jar. Neither loosens Http::safe(): a setClient() or a handler option on a guarded Laravel request is still refused. Without curl both refuse with cannot_pin.

A Guzzle client, guarded

use RoundlyConsulting\SafeFetch\Facades\SafeFetch;

$client = SafeFetch::guzzleClient(['base_uri' => 'https://api.partner.example/', 'timeout' => 10]);

$response = $client->get('v1/items');
$client->post('v1/hooks', ['json' => ['url' => $tenantUrl]]);

// Redirects are the guard's: turn them on in the policy, never in the client config
$client = SafeFetch::guzzleClient(policy: SafeFetch::policy()->followRedirects());

$config is Guzzle’s client config. Two keys are refused with UnsafeRequestOptions, because they would take the requests around the guard:

  • handler — it would replace the guarded stack. Push middleware onto SafeFetch::handlerStack() instead.
  • allow_redirects other than false — the guard follows redirects itself, vetting every hop. Turn them on in the policy.
SafeFetch::guzzleClient(['handler' => $myStack]);         // UnsafeRequestOptions — push middleware onto handlerStack() instead
SafeFetch::guzzleClient(['allow_redirects' => true]);     // UnsafeRequestOptions — use the policy's followRedirects()

Every other client option works as on a plain Guzzle client: http_errors (on by default, so a 4xx or 5xx throws Guzzle’s ClientException or ServerException), headers, auth, cookies => true, connect_timeout, on_stats, async requests and GuzzleHttpPool. Per request, the guard refuses what it refuses on Http::safe(): a proxy you chose (environment proxies are bypassed), a raw curl option outside the allow-list, transport_sharing, and a sink while a body cap is set. A refusal of the guard is a DestinationRejected or DestinationUnavailable, thrown as is — a rejected promise for an async request.

A handler stack for a client the library builds

use GuzzleHttp\Client;
use RoundlyConsulting\SafeFetch\Facades\SafeFetch;

$stack = SafeFetch::handlerStack(SafeFetch::policy()->allowHosts(['*.partner.example']));

$client = new Client(['handler' => $stack, 'base_uri' => 'https://api.partner.example/']);

The stack is the one a guarded Laravel request sends through — the guard’s own curl handler, the guard middleware, Laravel’s stub and recorder — and on top a small layer that turns a Guzzle client’s defaults into what the guard runs on:

  • Redirects off — a plain Guzzle client follows redirects itself. On this stack it gets the redirect back, unless the policy follows it (followRedirects()), every hop vetted and pinned.
  • The client’s cookie jar handed to the guard, which applies it per hop the way Guzzle’s own redirects do.
  • Middleware a library pushes onto the stack sits inside the guard, right above its handler: it runs on every hop, and a request it re-points after vetting (another URL, a Host header) is refused with cannot_pin before anything is dialled. Middleware it unshift()s sits outside and runs once per call.
  • Http::globalMiddleware() applies, as on Http::safe(); Http::globalOptions() does not — the client’s own options do. A handler in Http::globalOptions() is refused with UnsafeRequestOptions.

Caps

Both methods follow the policy’s caps, so request.max_bytes and request.timeout apply to them too. That matters most here: a Guzzle client has no timeout by default. With request.timeout set, a client without one gets it and a longer one is cut down to it, every hop included. A body over request.max_bytes is cut and marked — SafeFetch::truncated($response) reads a PSR-7 response as well. Per call: SafeFetch::policy()->timeout(10)->maxResponseBytes(1_048_576), or timeout(null) and maxResponseBytes(null) to lift them.

Concurrency and DNS rebinding

Each hop is resolved once and curl is pinned to exactly the addresses judged (CURLOPT_RESOLVE), so a DNS answer that changes between the check and the connection is never dialled; a later request is vetted afresh. Concurrent requests of one client (getAsync(), GuzzleHttpPool) share one curl multi handle, like the members of an Http::pool(): each hop carries its own pin, a fresh connection and no reuse, and two concurrent requests to one host and port whose vetted addresses differ are refused rather than mixed.

Testing

Http::fake() answers and records requests sent through either method, as it does for Http::safe(): Http::assertSent(), preventStrayRequests() and the stubs all apply. Under SafeFetch::fake() every hop is recorded (assertVetted()), and a request no Http::fake() stub answers is refused before dialling.

What is not guaranteed

  • The stack is only as safe as its handler. Code that calls setHandler() on it, or remove()s the guard, takes it off the path; on Guzzle 7 a per-request handler option replaces the whole stack too (Guzzle 8 refuses that option itself). Hand the stack only to code that uses it as a handler.
  • stream => true does not stream: the guard’s handler is curl, which downloads the whole body first (a server-sent-events reply arrives when the server ends it). Bound it with request.max_bytes or the policy.
  • Laravel’s ResponseReceived event is not dispatched for these requests; RequestSending is.

laravel/mcp — a transport subclass

Checked against laravel/mcp 1.1.2 and laravel/ai 1.3.0 (Guzzle 8.3); re-check after upgrading them. Neither builds its own Guzzle client — both send through Laravel’s Http facade from inside their own classes. laravel/mcp’s HttpTransport sends every JSON-RPC message from send() and ends the session from terminateSession(), each with Http::withHeaders(…). Subclass it and build those two requests with SafeFetch::http(); everything else is protected and reused as is:

final class GuardedMcpTransport extends HttpTransport
{
    // send() and terminateSession(): HttpTransport's own bodies, with
    // Http::withHeaders(...) replaced by
    // SafeFetch::http($this->policy)->withHeaders(...).
}

$client = new WebClient(new GuardedMcpTransport($tenantMcpUrl));
$tools = $client->connect()->tools();

// A named client: registered through laravel/mcp's own factory hook, so Mcp::client('partner')
// and a queued or cached copy of it are rebuilt with the guarded transport.
Mcp::registerClient('partner', fn () => new WebClient(new GuardedMcpTransport($partnerUrl)));
  • Copy the method bodies from the laravel/mcp version you run, and re-copy on upgrade.
  • Clients defined in config/mcp.php use the plain transport — register them with Mcp::registerClient() instead.
  • A client serialised without a name is rebuilt with the plain transport; a named one is rebuilt through Mcp::registerClient()’s factory.
  • The OAuth discovery pins addresses itself and follows Guzzle’s redirects with a check per hop; it cannot run inside the guard, which refuses Guzzle’s redirects.

laravel/ai — fetch remote files yourself

  • Provider requests have no per-provider hook, and the guard cannot be an app-wide Http::globalMiddleware() (it needs its own handler and would refuse redirects on every request in the app). If a tenant can set a base URL (an OpenAI-compatible or Ollama URL), judge it when it is saved — SafeFetch::vet($url) or the safe_url rule — and keep egress rules at the network edge; that check is not pinned at send time.
  • Remote files (Image::fromUrl(), Document::fromUrl(), Audio::fromUrl()) go through laravel/ai’s own guard, which cannot be swapped for this one. To apply your policy — allowlist, blocked ranges, the cloud-metadata table — fetch the file yourself and hand over the bytes (below). A gateway that sends the URL itself to the provider fetches nothing on your server.
  • Bedrock builds an AWS SDK client with its own handler; there is no hook, and its endpoints are AWS’s.
$fetched = SafeFetch::fetch($url, maxBytes: 10_000_000, accept: 'image/*');

$image = Image::fromBase64(base64_encode($fetched->body), $fetched->contentType);

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.