Two helpers, both on the facade. SafeFetch::fake() records every verdict and still performs it — it keeps the real guard, so a private address is still refused. SafeFetch::fakeDns() answers the guard’s lookups from a table. Neither fakes the HTTP requests: answer those with Http::fake().
SafeFetch::fake() — record every verdict
use RoundlyConsulting\SafeFetch\DataTransferObjects\VettedDestination;
use RoundlyConsulting\SafeFetch\Enums\Reason;
$fake = SafeFetch::fake(['pinned.example' => '151.101.1.1']); // optional DNS pins
Http::fake(['*' => Http::response('ok')]);
// The code under test:
SafeFetch::fetch('https://feed.example/rss');
Http::safe()->post('https://hooks.example/a', ['event' => 'ping']);
SafeFetch::allows('https://10.0.0.1/');
$fake->assertFetched('https://feed.example/rss'); // or a Closure(string $url): bool
$fake->assertVetted('https://hooks.example/a'); // or a Closure(RecordedVet): bool
$fake->assertNotVetted('https://other.example/');
$fake->assertRefused('https://10.0.0.1/', Reason::Blocked); // reason optional
$fake->assertGuardedRequestSent(fn (VettedDestination $hop) => $hop->host === 'hooks.example');
$fake->assertNothingDownloaded();
$fake->recorded(); // list<RecordedVet>
SafeFetch::assertRefused('https://10.0.0.1/', Reason::Blocked); // the same assertions, on the facade- It swaps the manager behind the facade and in the container — Testing\SafeFetchFake extends SafeFetchManager, so an injected manager is the fake too.
- It records vet(), allows(), precheck() (via precheck, no vetted destination — nothing is resolved), every hop of a guarded request (via http) and every fetch() and download(). URLs match as written or once both are rebuilt the guard’s way.
- Pass ['host' => 'address'] (or a list of addresses, or [] for unresolvable) to pin names. Unmapped names resolve to the public 93.184.216.34; ->strictDns() makes them unresolvable, and ->dns() returns the table.
- A guarded request no Http::fake() stub answered is refused before dialling with a LogicException — fetch() and download() throw it as is, never as a FetchFailed — so a test that forgot Http::fake() never reaches a real server. ->allowRealRequests() lets it through.
- Every assertion is also callable on the facade once the fake is installed.
Assertions
| Assertion | Passes when |
|---|---|
assertVetted(string|Closure $url) | The URL was judged (vet, allows, precheck, a hop), or a verdict matched the callback. |
assertNotVetted(string $url) | The URL was never judged. |
assertRefused(string $url, ?Reason $reason = null) | The URL was refused (for $reason, when given). |
assertNothingRefused() | No verdict was a refusal — the failure message lists each refused URL and its reason. |
assertNothingVetted() | Nothing was judged. |
assertFetched(string|Closure $url) · assertNothingFetched() | fetch() was (not) called for it. |
assertDownloaded(string|Closure $url) · assertNothingDownloaded() | download() was (not) called for it. |
assertGuardedRequestSent(?Closure $callback = null) | A guarded request (Http::safe(), guard(), http(), fetch(), download()) passed the guard on a hop the callback accepts. |
Recorded verdicts
recorded() returns list<Testing\RecordedVet>, one per verdict in order — the shape an assertVetted(fn (RecordedVet $vet) => …) callback receives:
| Field | Meaning |
|---|---|
url (string) | The URL as it was handed in. |
via (string) | vet, allows, precheck, or http for a hop of a guarded request (Http::safe(), guard(), http(), fetch(), download()). |
vetted (?VettedDestination) | The accepted destination; null for a refusal and for every precheck (nothing is resolved). |
reason (?Reason) | Why it was refused; null when it passed. |
refused(): bool | reason !== null. |
SafeFetch::fakeDns() — a DNS table
$dns = SafeFetch::fakeDns([
'hooks.example' => '93.184.216.34',
'dual.example' => ['93.184.216.34', '2606:4700::1111'],
'gone.example' => [], // does not resolve
]);
$dns->resolve('cdn.example', '151.101.1.1'); // pins add up across calls
$dns->unresolvable('hooks.example');
Http::fake(['hooks.example/*' => Http::response('ok')]);| Method | Does |
|---|---|
resolve(string $host, string|array $addresses) | Pin a name to one or more addresses; pins add up and chain. |
unresolvable(string $host) | The name does not resolve. |
lookups() | The names looked up, in order. |
assertLookedUp(string $host, ?int $times = null) | The name was looked up (exactly $times, when given). |
assertNotLookedUp(string $host) · assertNothingLookedUp() | The name, or no name at all, was looked up. |
- A name the table was not given does not resolve, so a test never reaches real DNS.
- Names are matched the way the guard writes them — lowercase, no trailing dot, punycode.
- Pinning an address literal, a non-name (/?#@:[]) or a non-address throws InvalidArgumentException. localhost and *.localhost may be pinned — a trusted private host is looked up.
- fakeDns() and $fake->dns() return the same Testing\FakeHostResolver; the table lives in the container and ends with the test.
See the pin in a test
Laravel hands a stub callback the request options, so a test can see the pin:
$dns = SafeFetch::fakeDns(['hooks.example.com' => '93.184.216.34', 'internal.example.com' => '10.0.0.5']);
Http::fake(fn (Request $request, array $options) => Http::response($options['curl'][CURLOPT_RESOLVE][0]));
$pinned = $safeFetch->guard(Http::timeout(10), $policy)->post('https://hooks.example.com/in', ['event' => 'ping'])->body();
$dns->assertLookedUp('hooks.example.com', 1); // $pinned === 'hooks.example.com:443:93.184.216.34'Exceptions in your own doubles
The named constructors (see Verdicts and exceptions) let a test double throw exactly what the package throws.
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.