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

Fetch and download

fetch() — a GET into memory

A guarded GET whose body is read into memory, within a byte cap and one time budget:

$page = SafeFetch::fetch(
    $url,
    maxBytes: 1_000_000,          // default safe-fetch.fetch.max_bytes (5 MiB)
    timeout: 20,                  // default safe-fetch.fetch.timeout (30 s), every hop together, 1–600
    accept: 'text/html',
    headers: ['X-Trace' => $id],  // never Host; sent to the first origin only
    followRedirects: true,        // default: the policy's redirects
    policy: SafeFetch::policy(),  // default: the configured policy
);

$page->status;       // 2xx
$page->body;         // the whole body, within the cap
$page->contentType;  // or null
$page->url;          // the URL the answer came from (the last hop, rebuilt)
$page->headers;      // array<string, list<string>>
$page->header('etag');
$page->json('data.items', []);  // the body decoded as JSON (null when it is not), a dot-notation key

FetchedResponse::json(?string $key = null, mixed $default = null) decodes the body on every call (objects as arrays); a body that is not valid JSON is null, so a key reads $default.

download() — the same GET, streamed to a file

For a file too large to hold in memory — an import, a media upload by URL. It takes the same options as fetch() and fails the same way; the body goes through the same cap into the file, never into memory:

$file = SafeFetch::download($url, $path, maxBytes: 50_000_000);   // $path: e.g. storage_path('app/imports/feed.xml')

$file->status;       // 2xx
$file->path;         // where the body is — the path you gave
$file->bytes;        // its size, as written (decoded)
$file->contentType;  // or null
$file->url;          // the URL the answer came from (the last hop, rebuilt)
$file->headers;      // array<string, list<string>>
$file->header('etag');
  • The directory must exist and be writable, and the path must not be a read-only file or a directory — otherwise InvalidArgumentException, before anything is sent.
  • The body goes to a temporary file next to $path (.<name>.<random>) and is renamed over $path only when the download succeeded — an atomic swap on one filesystem. A file already at $path stays whole meanwhile and keeps its file mode; a new file gets the umask’s.
  • A symlink at $path is written through, as a plain write would: the temporary file sits next to the link’s target and replaces that, and the link stays a link.
  • Every hop starts the temporary file afresh, so a redirect’s body never lands in it.
  • On any failure the temporary file is deleted — a body over the cap, a non-2xx status, a refused redirect, a dropped connection, a full disk — and a file already at $path is left as it was. A rename that fails throws RuntimeException.
  • If the worker dies hard mid-download, the temporary .<name>.XXXXXX file can remain in that directory — safe to delete; nothing reads it.

Behaviour of both

  • Only a 2xx body is read. Any other answer is judged by its status and headers alone — the transfer stops before its body, so a 503 with a huge error page stays a retryable 503. An informational answer (103 Early Hints) is passed over.
  • The byte cap: a 2xx declaring a Content-Length over the cap stops before its body; any other body is cut off as it passes the cap — counted after decoding, so a compressed bomb stops too. Either way: FetchFailed (too_large).
  • Redirects: followRedirects: true (or the policy) follows all five statuses for a GET unless the policy names its own; only the final body is kept.
  • Headers you pass go to the first origin only, and a Host header is refused (UnsafeRequestOptions).
  • The request comes from the container’s HTTP client factory, so Http::fake() answers and records it.

Failures

WhatException (reason)retryable()
refused by the guardDestinationRejectedno
host does not resolve (at vetting or curl 6)DestinationUnavailable (unresolved)yes
budget spent between hopsDestinationUnavailable (timeout)yes
non-2xxFetchFailed (status)408, 429, 5xx
over the capFetchFailed (too_large)no
curl 28FetchFailed (timeout)yes
curl 51, 53, 54, 58, 59, 60, 64, 66, 77, 80, 82, 83, 90, 91FetchFailed (tls)no
any other curl errorFetchFailed (connection)yes
not readable HTTP (a status outside 100–599)FetchFailed (protocol)no
the body could not be stored (a full disk, a failing php://temp spill)RuntimeException— a local failure, never a FetchFailed
a Host headerUnsafeRequestOptions—
download(): no existing, writable directory for the pathInvalidArgumentException—
use RoundlyConsulting\SafeFetch\Exceptions\FetchFailed;
use RoundlyConsulting\SafeFetch\Exceptions\SafeFetchException;

try {
    $page = SafeFetch::fetch($url, maxBytes: 1_000_000);
} catch (FetchFailed $e) {
    $e->reason();       // Reason::Status, TooLarge, Timeout, Connection, Tls or Protocol
    $e->status();       // the HTTP status for Reason::Status, otherwise null
    $e->retryAfter();   // the server's Retry-After in seconds, for 408 / 429 / 5xx
    $e->retryable();
} catch (SafeFetchException $e) {
    $e->retryable();    // DestinationUnavailable (unresolved, timeout): yes · DestinationRejected: never
}
  • Retry-After is read as seconds or an HTTP date, capped at 86 400.
  • A transport failure’s getPrevious() is a RuntimeException naming only the underlying exception’s class and curl code — never Guzzle’s own message, which names the URL and the address it connected to.
  • Only transport failures are mapped. Anything else reaches the caller as thrown — a refusal of the guard, the fake’s LogicException for a request no Http::fake() stub answered, Http::preventStrayRequests()’s StrayRequestException, a programmer error. download() then leaves a file already at the path as it was.

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.