Http::fake() Sequences for Testing Flaky Third-Party APIs
Most Http::fake() examples stop at the happy path: fake one successful JSON response, assert the code handles it. That's fine until you're testing retry logic, circuit breakers, or fallback behavior — code whose entire purpose is reacting to a sequence of different responses, not one. Http::fake() supports exactly that through response sequences.
The basic single-response fake
Http::fake([
'api.paymentgateway.test/*' => Http::response(['status' => 'approved'], 200),
]);
Fine for testing the success path. But PhpArchitect's payment integration retries a failed charge up to three times before giving up, and that behavior needs a test that actually fails a couple of times first.
Http::sequence(): scripting a series of responses
use Illuminate\Support\Facades\Http;
it('retries a failed charge up to three times before succeeding', function () {
Http::fake([
'api.paymentgateway.test/*' => Http::sequence()
->push(['error' => 'gateway_timeout'], 504)
->push(['error' => 'gateway_timeout'], 504)
->push(['status' => 'approved'], 200),
]);
$result = (new ChargeCustomer)->handle($order);
expect($result->approved)->toBeTrue();
Http::assertSentCount(3);
});
Each call to the matched URL pattern pulls the next response off the sequence in order. The first two calls return a 504, the third returns success — exactly modeling "flaky API that recovers on the third attempt" without a single real network call.
Testing what happens when the retries run out
The opposite case — the API never recovers — is just as important to cover, and just as easy to script:
it('gives up and marks the charge as failed after exhausting retries', function () {
Http::fake([
'api.paymentgateway.test/*' => Http::sequence()
->push(['error' => 'gateway_timeout'], 504)
->push(['error' => 'gateway_timeout'], 504)
->push(['error' => 'gateway_timeout'], 504)
->push(['error' => 'gateway_timeout'], 504),
]);
$result = (new ChargeCustomer)->handle($order);
expect($result->approved)->toBeFalse();
expect($order->fresh()->status)->toBe('payment_failed');
});
Without sequences, testing this path usually means either mocking the HTTP client at a lower level or writing a fake class that implements retry counting itself — both heavier than describing the four responses your retry logic will actually see.
whenEmpty(): what happens after the sequence runs out
If your code makes more requests than you scripted responses for, Http::sequence() throws by default, which is usually what you want — it means your assumption about the call count was wrong. If you'd rather fall back to a default response after the sequence is exhausted, set it explicitly:
Http::fake([
'api.paymentgateway.test/*' => Http::sequence()
->push(['error' => 'gateway_timeout'], 504)
->whenEmpty(Http::response(['status' => 'approved'], 200)),
]);
A note on ordering across multiple URLs
Sequences are scoped per fake URL pattern, not globally — faking two different endpoints each with their own Http::sequence() tracks each endpoint's call count independently. That makes it straightforward to test a webhook handler that calls both a payment gateway and a notification service in the same request, each with its own scripted behavior, without the two sequences interfering with each other.