โ† All guides

Mock APIs in Laravel โ€” Http::fake() and when the mock needs to be a real URL

Honest split first: Http::fake() is one of the nicest HTTP stubbing APIs anywhere. URL patterns, Http::response(), fakeSequence(), assertSent() โ€” for fast offline feature tests of code that calls external APIs through the Http facade, it's exactly the right tool and this page is not going to pretend otherwise.

Everything below was verified the week of writing (Aug 2026, Laravel 13.23, PHP 8.3, PHPUnit 12) โ€” every snippet on this page was actually run.

The three boundaries that send people searching

1. Unmatched requests silently hit the real network

This is the one that bites in CI. Fake one host and the default for every other request is passthrough โ€” no warning, no failure:

Http::fake([
    'api.example.com/*' => Http::response(['ok' => true]),
]);

// NOT matched by the fake โ€” and Laravel silently sends it for real:
$res = Http::get('https://mockbird.mockbird.workers.dev/m/demo/products?limit=2');
$res->status();          // 200 โ€” from the real server
count($res->json());     // 2 โ€” real records came back

We ran exactly that: the unmatched request returned live seeded data over the network, mid-test. If that URL had been a payment provider, the test would have charged someone. The fix is one line โ€” put it in your base TestCase::setUp():

Http::preventStrayRequests();
// now any unfaked request throws:
// StrayRequestException: "Attempted request to [https://โ€ฆ] without a matching fake."

2. Only the Http facade is faked โ€” Guzzle and curl sail past it

We faked everything (Http::fake(['*' => โ€ฆ])) and then made the same request three ways:

Http::fake(['*' => Http::response(['faked' => true])]);

Http::get($url)->json();                  // ['faked' => true]  โœ… faked

(new \GuzzleHttp\Client())->get($url);    // real network       โŒ not faked
curl_exec(curl_init($url));               // real network       โŒ not faked

Any SDK that news up its own Guzzle client (most payment/shipping/analytics SDKs), any legacy service class using raw curl, any Symfony HttpClient call โ€” Http::fake() never sees them. It hooks Laravel's HTTP client factory, not PHP's network layer.

3. The fake never leaves your test process โ€” so Dusk gets nothing

This is the Dusk question that lands people here. Http::fake() mutates state inside the process running the test. Laravel Dusk drives a real browser against an app served by a different PHP process โ€” the fake cannot reach it. Same story for queue workers, scheduled commands, Octane workers, and your Vue/Livewire frontend's own fetch calls.

We proved it the direct way: an app route that calls Http::get() upstream, served by php artisan serve; the test process ran Http::fake(['*' => โ€ฆ]) and then hit that route like a browser would:

// routes/web.php (running under `php artisan serve` โ€” a separate process)
Route::get('/proxy-products', function () {
    $res = Http::get('https://mockbird.mockbird.workers.dev/m/demo/products?limit=2');
    return $res->json();
});

// In the test process:
Http::fake(['*' => Http::response(['faked' => true])]);
$body = json_decode((new \GuzzleHttp\Client())
    ->get('http://127.0.0.1:8909/proxy-products')->getBody(), true);

$body[0]['price'];              // real upstream data โ€” the fake never applied

No amount of Http::fake() configuration fixes any of these three โ€” the mock has to live somewhere every process and every client can reach: a URL.

The 60-second version

# one curl, no signup โ€” a whole seeded e-commerce backend
curl -X POST https://mockbird.mockbird.workers.dev/api/projects \
  -H 'content-type: application/json' -d '{"name":"shop","preset":"ecommerce"}'
# โ†’ {"id":"abc123","adminKey":"KEY", ...}   โ† save both

curl https://mockbird.mockbird.workers.dev/m/abc123/products?limit=3
# โ†’ 3 seeded products, CORS on, writes persist

Then wire it the way Laravel already wants external APIs wired โ€” config/services.php plus an env var:

// config/services.php
'catalog' => [
    'base_url' => env('CATALOG_API_URL', 'https://api.yourapp.com'),
],

// anywhere in the app
$res = Http::baseUrl(config('services.catalog.base_url'))->get('/products');
# .env.testing / .env.dusk.local / CI
CATALOG_API_URL=https://mockbird.mockbird.workers.dev/m/abc123

Now the Dusk browser, the queue worker, the frontend's fetch, the Guzzle-based SDK and your terminal all see the same mock โ€” because it's just a URL. Prefer clicking? This link creates the same project in your browser โ€” no account. Have an OpenAPI spec? Paste it at /app#import.

Error, latency and chaos states your fakes can't exercise honestly

A faked response returns instantly, so retry waits and timeout durations never actually run. Against a hosted URL, real time passes โ€” all three of these are from the verified test suite:

// a real 503, no fake handler to write
$res = Http::get('โ€ฆ/m/demo/products?mock_status=503');
$res->serverError();   // true

// a real timeout: response held for 3s, 1s timeout genuinely fires
Http::timeout(1)->get('โ€ฆ/m/demo/products?mock_delay=3000');
// โ†’ Illuminate\Http\Client\ConnectionException

// Http::retry() against a genuinely flaky endpoint:
// mock_chaos=0.5 fails ~half of requests with real 5xx/429s
$res = Http::retry(5, 100, throw: false)
    ->get('โ€ฆ/m/demo/products/1?mock_chaos=0.5');
$res->status();        // 200 โ€” five runs in the suite, five 200s after retries

More recipes in testing loading & error states.

State: scripted answers vs. an actual store

Http::fakeSequence() scripts responses in order โ€” call it a third time when you pushed two and you get OutOfBoundsException: A request was made, but the response sequence is empty (verified). And a faked POST changes nothing: the next GET returns the same canned body, because there is no store behind the script. Against Mockbird the write is real:

$created = Http::post("$base/products", ['name' => 'Laravel guide test product', 'price' => 9.99]);
$id = $created->json('id');                       // 201, real id
Http::get("$base/products/$id")->json('name');    // the record is actually there
Http::delete("$base/products/$id");               // and cleanup is real too โ€” then GET โ†’ 404

For deterministic test data across runs (and parallel Dusk workers), save a named snapshot and pin it per-request with the X-Mockbird-Snapshot header โ€” deterministic test data guide.

Http::fake() concepts โ†’ Mockbird

LaravelMockbirdNotes
Http::fake(['host/*' => Http::response($data)])a resource on a real URLlist/get/create/update/delete generated, plus filtering, sorting, pagination, relations
Hand-rolled response arraysseeded realistic recordsfaker-style names/emails/prices/dates; or import your exact records from db.json/CSV/OpenAPI
Http::response([], 500)?mock_status=500on any URL, no handler edit
(no latency simulation)?mock_delay=3000 / ?mock_jitterreal time passes โ€” Http::timeout() genuinely fires
(no flakiness simulation)?mock_chaos=0.5real 5xx/429s for Http::retry() to absorb
fakeSequence()snapshot pinningnamed data states, no script-exhaustion exception, safe across parallel workers
Http::assertSent(fn โ€ฆ)request inspectorlast 50 requests with method, path, query, headers, body
preventStrayRequests()n/akeep using it! it guards the fakes you keep

Try it in 10 seconds (shared demo, no setup)

curl 'https://mockbird.mockbird.workers.dev/m/demo/products?limit=2&select=name,price'
curl -i 'https://mockbird.mockbird.workers.dev/m/demo/products?mock_status=503'
curl 'https://mockbird.mockbird.workers.dev/m/demo/products/1?mock_delay=2000'

All against the shared demo project (resets daily).

Or use both โ€” they compose

Honest comparison

Http::fake()Mockbird
What it isbuilt into Laravelfree hosted service
Reachable fromthe Http facade, in the test process onlyanything with HTTP: browser, Dusk, curl, Guzzle, SDKs, queue workers, mobile, CI, teammates
Guzzle / curl / SDK-internal clientsnot intercepted (verified)it's a real URL โ€” every client works by definition
Unmatched requestsilently hits the real network (default; verified)n/a โ€” real endpoints answer real queries
Setupwrite + maintain fake arrays per testone curl or one click; no code
Stateful CRUDno โ€” scripted answers onlydefault โ€” writes persist
Latency/retry/timeout testinginstant responses โ€” durations never run?mock_delay/?mock_jitter/?mock_chaos, real time passes
Works offlineyesno โ€” it's a real network call
Request assertionsassertSent(), precise, in-testrequest inspector (last 50, headers/body)
Request capnone10,000/project/day

Written by the Mockbird maker โ€” bias disclosed. Where Http::fake() genuinely wins: it ships with Laravel, runs offline at zero latency, assertSent() closures are more precise than any log-based check, and a fake callback is arbitrary PHP. For fast feature tests of facade-based code it should stay your default. When the thing you need is a URL โ€” for Dusk, a queue worker, a Guzzle SDK, a frontend fetch, a teammate, or CI against a deployed preview โ€” that's us.

Full API reference in the docs. More guides: mock API for PHP ยท mock APIs in Cypress ยท mock APIs in Playwright ยท deterministic test data ยท testing loading & error states ยท free mock API tools compared. Create your API โ†’