---
title: "Third-party clients — Safe Fetch for Laravel | Roundly"
description: "Guard a library’s own Guzzle or PSR-18 client with guzzleClient() and handlerStack(), with caps and testing, plus notes on laravel/mcp and laravel/ai."
url: https://roundly-consulting.com/open-source/docs/safe-fetch-for-laravel/third-party-clients
language: en
---

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

[Safe Fetch for Laravel](https://roundly-consulting.com/open-source/docs/safe-fetch-for-laravel.md)

# Third-party clients

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

| The library… | Use | Guarded |
| --- | --- | --- |
| 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 handler | `SafeFetch::handlerStack(?DestinationPolicy $policy = null)` | yes |
| sends through Laravel’s Http facade inside its own code | no 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

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

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

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

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

```php
$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](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/safe-fetch-for-laravel.md)
