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

Verdicts and exceptions

Reasons

Enums\Reason is string-backed. Reason::retryable() returns the column below; for status it returns false and the exception decides:

ValueThrown asretryable()Meaning
invalid_urlDestinationRejected—Not a URL the guard can parse one way only (a control character, no authority, a backslash).
schemeDestinationRejected—Not http(s), or a scheme the policy does not allow (https only by default).
credentialsDestinationRejected—User info (user:pw@) in the authority.
non_canonical_hostDestinationRejected—A host spelled in a form parsers disagree on (numeric IPv4, %, a zone id, whitespace).
portDestinationRejected—Outside 1–65535, or a port the policy does not allow.
blockedDestinationRejected—A private, loopback, metadata or blocked destination — blockedBy() names the rule.
unresolvedDestinationUnavailableyesThe host name does not resolve. DNS can heal.
not_allowedDestinationRejected—A strict host allowlist does not list the host.
cannot_pinDestinationRejected—The connection could not be pinned to the vetted addresses (no curl, or it went elsewhere).
too_many_redirectsDestinationRejected—More redirects than the policy allows.
redirect_downgradeDestinationRejected—A redirect from https to http.
timeoutDestinationUnavailable · FetchFailedyesThe time budget ran out — between hops, or curl 28 in fetch() / download().
statusFetchFailed408, 429, 5xxfetch() or download() got a non-2xx answer; status() and retryAfter() on the exception.
too_largeFetchFailed—A body larger than the byte cap.
connectionFetchFailedyesThe connection failed or broke off.
tlsFetchFailed—The server’s TLS certificate could not be verified.
protocolFetchFailed—The answer was not readable HTTP (a status outside 100–599, a broken head).

The enum carries the shared helpers of enums-for-laravel (values(), names(), options(), label(), is(), isIn(), fromName(), validationRule(), …):

use RoundlyConsulting\SafeFetch\Enums\Reason;

Reason::values()->all();               // ['invalid_url', 'scheme', 'credentials', …]
Reason::Unresolved->retryable();       // true
Reason::from($row->reason)->isIn([Reason::Blocked, Reason::NotAllowed]);

Exceptions

RuntimeException
└── SafeFetchException (abstract)      reason(): Reason · retryable(): bool · url(): string · host(): string
    ├── DestinationRejected            + blockedBy(): ?string
    ├── DestinationUnavailable
    └── FetchFailed                    + status(): ?int · retryAfter(): ?int
LogicException
└── UnsafeRequestOptions               a programmer error; names the option
  • url() returns the URL as the caller handed it in; host() the canonical host the verdict is about (empty when the URL had none the guard could read). Neither is in the message.
  • FetchFailed::retryAfter() is the server’s Retry-After in seconds (seconds or an HTTP date, capped at 86 400), only for a retryable status.
  • UnsafeRequestOptions is thrown for an option that would take a request around the guard (see Guarded requests). Its message names the option, in English only — a programmer error, like Laravel’s own exceptions.

Messages that give nothing away

Messages are fixed per reason (safe-fetch::messages.<reason>, English and Slovak) and never contain the URL or an address. blocked and unresolved read the same — “The URL cannot be fetched: its destination is not allowed or cannot be reached.” — so the message is safe to show the person who submitted the URL: telling “private” from “not found” would map your internal network for whoever controls the URL.

reason(), retryable() and blockedBy() do tell them apart — they are for your code and your operators. Never let that difference reach the person who submitted the URL: a “try again later” shown only when retryable() is the same oracle again. Retry in the background, show one message.

blockedBy()

DestinationRejected::blockedBy() — operators only — names the rule behind a blocked verdict:

RuleMeaning
hostnamelocalhost or *.localhost, not listed as a private host.
blocked_hostA host in hosts.blocked / blockHosts() — refused before any lookup.
app_hostOne of the app’s own hosts, or an address literal that is one of its addresses, while the block is on.
blocked_rangeAn address in ranges.blocked / blockRanges().
private_addressAn address in the blocked-network table, neither in an allowed range nor (unless it is cloud metadata) the address of a private host.

Named constructors

The guard builds its exceptions through these; they are public, so a test double or a wrapper of your own can throw exactly what the package throws. $url is a #[SensitiveParameter] and never in the message:

DestinationRejected::because(Reason::Blocked, $url, 'internal.example', blockedBy: 'private_address');
DestinationUnavailable::unresolved($url, 'gone.example');                 // retryable
DestinationUnavailable::timedOut($url, 'slow.example');                   // retryable
FetchFailed::forStatus($url, 'api.example', 503, retryAfter: 30);         // status() 503, retryAfter() 30
FetchFailed::because(Reason::Connection, $url, 'api.example');            // retryable
UnsafeRequestOptions::option('proxy', 'a proxy resolves the host name itself.');

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.