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

The facade is the recommended default, not the only way in. Every facade method is a method of RoundlyConsulting\SafeFetch\SafeFetchManager — the facade’s root, a container singleton — and each resolves one action through the container, so a container override of an action and SafeFetch::fake() both apply. All three styles run the same code:

// Facade / macro
Http::safe()->withToken($token)->post($url, $payload);
$vetted = SafeFetch::vet($url);
$page = SafeFetch::fetch($url, maxBytes: 1_000_000);
$file = SafeFetch::download($url, $path, maxBytes: 50_000_000);
SafeFetch::precheck($url);

// Dependency injection
public function __construct(private SafeFetchManager $safeFetch) {}
$this->safeFetch->guard(Http::withToken($token), $policy)->post($url, $payload);
$this->safeFetch->vet($url);
$this->safeFetch->download($url, $path, maxBytes: 50_000_000);
$this->safeFetch->precheck($url);

// The actions
app(GuardRequestAction::class)->execute(Http::withToken($token), SafeFetch::policy())->post($url, $payload);
app(VetDestinationAction::class)->execute($url, SafeFetch::policy());
app(FetchUrlAction::class)->execute($url, new FetchOptions(maxBytes: 1_000_000), SafeFetch::policy());
app(DownloadUrlAction::class)->execute($url, $path, new FetchOptions(maxBytes: 50_000_000), SafeFetch::policy());
app(PrecheckDestinationAction::class)->execute($url, SafeFetch::policy());

Inject the manager

use Illuminate\Support\Facades\Http;
use RoundlyConsulting\SafeFetch\SafeFetchManager;

final class SendWebhook
{
    public function __construct(private SafeFetchManager $safeFetch) {}

    public function handle(string $url, array $payload): void
    {
        $this->safeFetch->guard(Http::timeout(10))->post($url, $payload);
    }
}

The manager is not final on purpose — the fake extends it, so code that injects SafeFetchManager is faked too under SafeFetch::fake().

Call an action

use RoundlyConsulting\SafeFetch\Actions\FetchUrlAction;
use RoundlyConsulting\SafeFetch\DataTransferObjects\FetchOptions;
use RoundlyConsulting\SafeFetch\Facades\SafeFetch;

$page = app(FetchUrlAction::class)->execute(
    $url,
    new FetchOptions(maxBytes: 1_000_000, timeout: 20, accept: 'text/html'),
    SafeFetch::policy(),
);

Facade method → action

Namespaces are relative to RoundlyConsulting\SafeFetch\Actions; every action is host-facing:

Facade methodActionexecute()
vet() · allows()VetDestinationAction(string $url, DestinationPolicy $policy, string $via = 'vet'): VettedDestination
precheck()PrecheckDestinationAction(string $url, DestinationPolicy $policy): CanonicalUrl
http() · guard() · Http::safe()GuardRequestAction(PendingRequest $request, DestinationPolicy $policy): PendingRequest
fetch()FetchUrlAction(string $url, FetchOptions $options, DestinationPolicy $policy): FetchedResponse
download()DownloadUrlAction(string $url, string $path, FetchOptions $options, DestinationPolicy $policy): DownloadedFile
truncated() · policy() · networks() · urls()—No action — stateless computation on the manager.
  • VetDestinationAction dispatches DestinationRefused on every refusal, with $via set to vet, allows, request, fetch or download; PrecheckDestinationAction dispatches it with via precheck.
  • fetch() and download() build their request from the container’s HTTP client factory, so Http::fake() answers and records it, and guard it like Http::safe().
  • The fake records calls made through the manager. Calling an action directly is not recorded as a vet, fetch or download — but every hop of a guarded request is, because the guard vets each hop through the manager.

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.