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:
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 requestWhat 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.
[
'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:
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 memberRetries
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:
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:
// 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:
$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
$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 cryptoBy 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.