Laravel Magazine
Http::fake() Sequences for Testing Flaky Third-Party APIs

Http::fake() Sequences for Testing Flaky Third-Party APIs

Eric Van Johnson ·

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.

Stay Updated

Subscribe to our newsletter

Get latest news, tutorials, community articles and podcast episodes delivered to your inbox.

Weekly articles
We send a new issue of the newsletter every week on Friday.
No spam
We'll never share your email address and you can opt out at any time.