---
title: "Guarded requests — Safe Fetch for Laravel | Roundly"
description: "Http::safe() step by step — the pinned curl handler, per-hop vetting, pre-send and post-checks, refused options, pools, retries and the response cap."
url: https://roundly-consulting.com/open-source/docs/safe-fetch-for-laravel/guarded-requests
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)

# Guarded requests

Http::safe() guards any Laravel HTTP request. The URL is rebuilt from a strict parse, every address its host resolves to is judged, the connection is pinned to those addresses through curl with no proxy and no reused connection, and the address curl connected to is checked:

```php
Http::safe()->post($url, $payload);                                          // the configured policy
Http::safe(SafeFetch::policy()->followRedirects())->post($url, $payload);    // a policy for this call
Http::withToken($token)->safe()->post($url, $payload);                       // chains either way
SafeFetch::guard(Http::withToken($token)->timeout(10), $policy)->post($url); // the manager, no macro
SafeFetch::http($policy)->get($url);                                         // a new guarded request
```

## What the guard does to a request

- Refuses without curl (cannot\_pin, before anything is built — fail closed).
- Refuses a request that already has a client of its own (setClient()), a Guzzle handler option (Http::globalOptions(), withOptions() before or after safe()) or that is already guarded — UnsafeRequestOptions.
- Sets its own pinned handler — always curl, never a stream handler (which would ignore the pin), never the host’s handler.
- Turns Guzzle’s own redirects off; redirects are the guard’s.
- Adds the guard middleware, which asks the manager to vet each hop, so SafeFetch::fake() sees every hop.
- Sends through its own client, which sees each request’s final options before Guzzle does — a handler added after safe() is refused at send time.

## Per hop

- The URL is vetted (SafeFetch::vet() semantics) — a refusal rejects the request and nothing is sent.
- The request goes to the rebuilt URL; on a hop to another origin than the first, credentials are dropped (see Redirects).
- The pinning options are added and win over the caller’s (below). With less than a millisecond of the budget left: DestinationUnavailable (timeout).
- The pinned handler re-checks the request right before curl: scheme, host and port are the vetted ones, a Host header names the same host and port, the proxy is off, redirects are off, the pins and the pre-send check are intact and no curl option outside the allow-list was added. Anything else is cannot\_pin, with nothing dialled.
- The pre-send check (CURLOPT\_PREREQFUNCTION): after curl connected and before it sends a byte, the connected address and port must be in the vetted set; else curl aborts → cannot\_pin. The server sees a connection and nothing on it.
- The post-check: the transfer stats’ primary\_ip must be in the vetted set; else cannot\_pin, reported and announced as DestinationRefused. Stats from Http::fake() are not checked. Your own on\_stats is still called.
- A redirect the policy follows becomes the next hop.

```php
[
    'proxy' => '',                    // no proxy at all, environment proxies included
    'allow_redirects' => false,       // redirects are the guard's
    'protocols' => [$scheme],         // Guzzle 7.11+; older 7.x ignore it (the scheme is vetted)
    'curl' => [
        CURLOPT_RESOLVE => ['host:port:a.b.c.d,[v6::1]'],  // every vetted address, one entry; none for a literal
        CURLOPT_FRESH_CONNECT => true,
        CURLOPT_FORBID_REUSE => true,
        CURLOPT_PREREQFUNCTION => $preSendCheck,         // libcurl 7.80+, not Guzzle 7.12.x
    ],
    'timeout' => <what is left of the budget>,
]
```

## Options the guard refuses

UnsafeRequestOptions — a programmer error, thrown, naming the option — for anything that would take a request around the guard:

- allow\_redirects other than false (->maxRedirects() after safe()) — enable redirects on the policy instead.
- A proxy the caller chose (withOptions(\['proxy' => …\]), Http::globalOptions()). Proxies that only repeat the environment (HTTP\_PROXY, HTTPS\_PROXY, NO\_PROXY) are bypassed, not refused: a proxy resolves the name itself.
- Request-level transport\_sharing.
- A raw curl option outside the allow-list: CURLOPT\_ENCODING, HTTPAUTH, USERPWD, SSL\_CIPHER\_LIST, TLS13\_CIPHERS, SSL\_EC\_CURVES, CERTINFO, LOW\_SPEED\_LIMIT, LOW\_SPEED\_TIME, TCP\_KEEPALIVE, TCP\_KEEPIDLE, TCP\_KEEPINTVL, TCP\_KEEPCNT, TCP\_NODELAY, TCP\_FASTOPEN, HAPPY\_EYEBALLS\_TIMEOUT\_MS, MAXAGE\_CONN and MAXLIFETIME\_CONN. Everything else — RESOLVE, CONNECT\_TO, UNIX\_SOCKET\_PATH, INTERFACE, DNS\_\*, PROXY\*, PRE\_PROXY, SSL\_VERIFYPEER … — fails closed.
- A sink together with maxResponseBytes.
- A client of its own (setClient()), a Guzzle handler option, or a request that is already guarded.

Refusals of the guard (DestinationRejected, DestinationUnavailable) surface unwrapped — Laravel does not turn them into RequestException or ConnectionException. A throwable from code inside the guard (Http::fake()’s StrayRequestException, a rate limiter added after safe(), a queue job’s release) reaches the caller as thrown. A transport failure of a guarded request is Laravel’s own ConnectionException, whose message names the URL — never echo it to an end user.

## Pools

The guard replaces a pool’s handler with its pinned handler — one per pool, shared by the pool’s guarded members — so the pool stays concurrent, while fresh connections and the per-hop pre-send check keep each member inside its own vetted set. Two members to the same host and port with different vetted sets are refused rather than mixed. A refused member’s result is the exception:

```php
use Illuminate\Http\Client\Pool;

$responses = Http::pool(fn (Pool $pool): array => [
    $pool->safe()->get('https://hooks.example/a'),
    $pool->safe()->get('https://inward.example/b'),
]);

$responses[0]->body();   // the answer
$responses[1];           // a DestinationRejected when the guard refused that member
```

## Retries

retry() re-enters the stack, so every attempt is vetted again. Laravel retries every throwable unless the when callback says no — a refusal included — so stop at the first:

```php
Http::safe()->retry(3, 100, fn (Throwable $e) => ! $e instanceof SafeFetchException)->post($url, $payload);
```

## Other middleware

The order is yours. A throttle added before the guard runs once per call, before any DNS lookup; one added after safe() runs on every hop. Prefer the first:

```php
// Runs once per call, before any DNS lookup — prefer this order
Http::withMiddleware($throttle)->safe()->post($url, $payload);

// http-client-rate-limits-for-laravel's macro works the same way
Http::rateLimit(30)->safe()->post($url, $payload);
```

## The response cap — maxResponseBytes

With the guard forcing curl, stream => true no longer streams: curl downloads the whole body before you see it. To bound a response, set a cap on the policy:

```php
$response = Http::safe(SafeFetch::policy()->maxResponseBytes(4096))->post($url, $payload);

$response->status();                       // the real status
$response->body();                         // at most 4 096 bytes
SafeFetch::truncated($response);           // true when the body was cut
$response->handlerStats()['primary_ip'] ?? null; // the address it connected to, truncated or not
```

- A body longer than the cap is cut and the transfer aborted — however the server sends it (no Content-Length, chunked, compressed: the count is of decoded bytes).
- The response keeps its status and headers and gains Safe-Fetch-Truncated: 1. A body of exactly the cap gets no marker.
- The guard strips a Safe-Fetch-Truncated header the server sent from every response, with or without a cap, so only the guard’s own cut sets it.
- The guard owns the sink: a caller sink together with the cap is refused. fetch() and download() have their own cap, which fails instead of truncating.

## Pin a request you build yourself

```php
$vetted = SafeFetch::vet($url);
Http::withOptions($vetted->pinningOptions())->get($vetted->url);
```

pinningOptions() is the option array above minus the pre-send check; it trusts the handler to honour curl options, and no hop is re-checked. Prefer SafeFetch::guard().

## Guzzle compatibility

illuminate/http 13 allows guzzlehttp/guzzle ^7.8.2 || ^8.0. The pinning options use only what every one of those accepts:

| Guzzle | Notes |
| --- | --- |
| `7.8.2` | The floor, tested on the prefer-lowest leg. No protocols option (ignored; the scheme is vetted anyway). Composer blocks Guzzle releases with security advisories by default — a host on such a Guzzle should upgrade. |
| `7.12.0–7.12.3` | CURLOPT\_PREREQFUNCTION is deprecated there (fixed in 7.13.0), so the pre-send check is skipped; the primary\_ip post-check remains. |
| `7.16.x` | Tested; the suite fails on any deprecation, @-suppressed ones included. |
| `8.x` | Tested on every PHP leg (8.4, 8.5, 8.6). |

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