skip to content

In a Laravel test suite, how do Http::fake() and Http::preventStrayRequests() keep tests from calling a real payment provider's refund API?

level: seniorimportance: should knowfreq 38%

answer

  1. fake with no args answers everything
  2. URL-pattern array stubs only matches
  3. unmatched requests still go live
  4. StrayRequestException on unmatched
  5. only Laravel's own HTTP client

basics

~10 s

Http::fake() answers requests made through Laravel's HTTP client with stubs and records them for Http::assertSent(). Http::preventStrayRequests() makes any request without a matching stub throw a StrayRequestException instead of reaching the network.

solid answer

~40 s

`Http::fake()` with no argument answers every request through the `Http` facade with an empty 200. With an array such as `Http::fake(['payments.test/v1/refunds*' => Http::response(['status' => 'succeeded'])])` it stubs only matching URLs, and a request that matches nothing is **sent for real**. `Http::preventStrayRequests()` closes that gap: an unmatched request throws `Illuminate\Http\Client\StrayRequestException` ("Attempted request to [...] without a matching fake."), and `Http::allowStrayRequests([...])` whitelists patterns such as a local service. Calling it in the base `TestCase::setUp()` makes it suite-wide. Assert what was sent with `Http::assertSent(fn (Request $r) => ...)`, `assertSentCount()` or `assertNothingSent()`, and script retries with `Http::sequence()`. The limit: only requests made through Laravel's HTTP client are covered, so a vendor SDK with its own HTTP client bypasses both.

code

php · 16 lines
php
<?php

namespace Tests;

use Illuminate\Foundation\Testing\TestCase as BaseTestCase;
use Illuminate\Support\Facades\Http;

abstract class TestCase extends BaseTestCase
{
    protected function setUp(): void
    {
        parent::setUp();

        Http::preventStrayRequests();
    }
}

go deeper

for a junior

Recall that Http::fake() stubs responses from the Http facade and that Http::assertSent() checks what the code sent.

for a middle

Explain the three fake shapes and that URL-pattern fakes let unmatched requests through unless preventStrayRequests() is on.

for a senior

Make the stray-request guard suite-wide, script failures with sequences, and identify SDKs that bypass the client so they are faked at your own seam.

for a principal

Treat outbound network access in tests as a policy: blocked by default, explicit allow lists, and owned interfaces around every third-party SDK.

## The problem A refund job calls a payment provider's API. A test that forgets to stub that call either fails intermittently, or worse, **issues a real refund** against a sandbox or live account. Laravel's HTTP client (the `Http` facade, a wrapper around Guzzle) has test support built in: stubs, recording, and a switch that turns unstubbed requests into errors. ## `Http::fake()` in its three shapes 1. **No argument**: `Http::fake()` makes every request return an empty `200`. Useful when the test does not care about the response. 2. **A URL-pattern array**: keys are patterns with `*` wildcards, values are responses. ```php Http::fake([ 'payments.test/v1/refunds*' => Http::response(['id' => 're_1', 'status' => 'succeeded'], 200), 'payments.test/v1/charges/*' => Http::response(['status' => 'paid']), ]); ``` A value may also be a bare status code (`500`), a string body, a closure receiving the request, or a sequence. 3. **A closure**: `Http::fake(fn (Request $request) => Http::response(...))` decides per request. Every faked request is **recorded**, so after the action you assert on it: - `Http::assertSent(fn (Request $r) => $r->url() === 'https://payments.test/v1/refunds' && $r['amount'] === 1500)` - `Http::assertNotSent(...)`, `Http::assertSentCount(1)`, `Http::assertNothingSent()` - `Http::assertSentInOrder([...])` for a multi-step exchange. ## The gap and how `preventStrayRequests()` closes it With the array or closure form, a request that matches no stub is **not** blocked: the client falls through to the real handler and hits the network. That is the classic leak: someone adds a call to a new endpoint, the pattern does not match, and CI starts talking to the provider. `Http::preventStrayRequests()` changes the fall-through. When no stub answers, the client throws `StrayRequestException` with the message "Attempted request to [URL] without a matching fake." The client rethrows it rather than converting it into a connection error, so it surfaces in the test. | Setup | Matched request | Unmatched request | |---|---|---| | `Http::fake()` | stubbed 200 | stubbed 200 (everything matches) | | `Http::fake([...patterns])` | stubbed | **sent for real** | | `preventStrayRequests()` + patterns | stubbed | `StrayRequestException` | | plus `allowStrayRequests(['http://127.0.0.1:5000/*'])` | stubbed | allowed if it matches the list, else exception | To make it **suite-wide**, call `Http::preventStrayRequests()` in `setUp()` of `tests/TestCase.php` (or a Pest `beforeEach`). A test that genuinely needs a live service opts in with `allowStrayRequests()`. ## Scripting retries with sequences A refund client usually retries on a 5xx. `Http::sequence()` returns responses in order: ```php Http::fake([ 'payments.test/*' => Http::sequence() ->pushStatus(503) ->push(['status' => 'succeeded'], 200), ]); ``` Once a sequence runs out it throws an `OutOfBoundsException` ("A request was made, but the response sequence is empty.") unless you call `whenEmpty()` or `dontFailWhenEmpty()`. `pushFailedConnection()` simulates a network failure. ## Faking failures, not just success Most refund bugs live in the failure paths, and the fake can produce each one: - `Http::response(['error' => 'insufficient_funds'], 402)` returns an error status, so you can test how the code reads the body and whether it calls `throw()`. - `Http::failedConnection()` produces a connection failure, which Laravel's client surfaces as `Illuminate\Http\Client\ConnectionException`, the same as a DNS or network error. - `Http::recorded()` returns the recorded request and response pairs when an assertion needs to inspect both sides. ## Where the fake stops - It hooks Laravel's HTTP client only. A vendor SDK that builds its own Guzzle or cURL client is neither stubbed nor blocked. Wrap such an SDK behind an interface you own and replace that in the container, or configure the SDK's own test mode. - `Process::fake()` and `Process::preventStrayProcesses()` give the same guarantee for shell commands run through Laravel's `Process` facade. ## Senior checklist - Turn on `preventStrayRequests()` globally, not per test. - Assert the **payload** you sent, not only that a request happened. - Cover the failure paths with `pushStatus(500)` and `pushFailedConnection()`. - Know which third-party SDKs sit outside the client and fake them at your own seam.

  • With preventStrayRequests() on, a test that exercises a vendor SDK still reaches the provider. Why?
    The guard lives inside Laravel's HTTP client. An SDK that creates its own Guzzle or cURL client never passes through it, so nothing is stubbed or blocked. Put the SDK behind an interface your app owns and bind a fake of that interface in the container for tests.
  • How do you let tests call a local stub server but still block everything else?
    Keep `Http::preventStrayRequests()` and add `Http::allowStrayRequests(['http://127.0.0.1:5000/*'])`. Requests matching an allowed pattern go through; any other unmatched request still throws `StrayRequestException`. Calling `allowStrayRequests()` with no argument turns the guard off entirely.

saying these in an interview costs you the question

  • Http::fake(['a.test/*' => ...]) blocks requests to every other host too
  • preventStrayRequests() also stops requests made by any PHP HTTP library
  • A stray request under preventStrayRequests() quietly returns an empty 200
  • Http::assertSent() only needs the URL, the payload does not matter
  • An exhausted Http::sequence() keeps repeating its last response