โ† All guides

Mock a REST API for PHP โ€” Guzzle, Laravel HTTP client and PHPUnit against a real URL

PHP has good in-process fakes: Guzzle ships a MockHandler that answers from a queue of canned responses, Laravel's Http::fake() is one of the nicest faking APIs anywhere (with preventStrayRequests() as a tripwire for accidental real HTTP), and php-vcr records real traffic to cassettes and replays it. For millisecond-fast unit tests they're the right tools, and this guide's comparison table says so plainly.

But an in-process fake can't help when you need a URL:

The boring fix is a hosted mock API. Every snippet below was run verbatim against the live demo project before publishing (PHP 8.3, Guzzle 7) โ€” you can paste and run them too.

1. Get an API (10 seconds, no signup)

curl -X POST https://mockbird.mockbird.workers.dev/api/projects \
  -H 'content-type: application/json' -d '{"name":"php-demo"}'
# โ†’ {"id":"abc123","adminKey":"...","baseUrl":".../m/abc123","dashboard":"/app#open=..."}

curl -X POST https://mockbird.mockbird.workers.dev/api/projects/abc123/resources \
  -H 'content-type: application/json' -H 'x-admin-key: YOUR_KEY' \
  -d '{"name":"users","template":"users","seed":50}'

That's 50 realistic users (names, emails, avatars, cities) with full CRUD, filtering, sorting, search and pagination โ€” and the create response includes a dashboard link that opens your project in a web UI, no terminal needed after this. The snippets below use the public demo project so they run with zero setup.

2. Zero-dependency quickstart

No Composer, no framework โ€” this runs on any PHP with allow_url_fopen:

<?php
$json = file_get_contents('https://mockbird.mockbird.workers.dev/m/demo/products?limit=3');
$products = json_decode($json, true);
foreach ($products as $p) {
    echo $p['id'], ': ', $p['name'], ' โ€” $', $p['price'], PHP_EOL;
}

Output when we ran it: three products with names and prices, straight from the live demo.

3. Guzzle client โ€” real pagination headers

<?php
require 'vendor/autoload.php';   // composer require guzzlehttp/guzzle

$api = new GuzzleHttp\Client([
    'base_uri' => 'https://mockbird.mockbird.workers.dev/m/demo/',
    'timeout'  => 10,
]);

// filtering, sorting, field projection โ€” all query params
$res = $api->get('products', ['query' => [
    'sortBy' => 'price', 'order' => 'desc', 'limit' => 3,
    'select' => 'name,price',
]]);

echo 'total in collection: ', $res->getHeaderLine('X-Total-Count'), PHP_EOL;
foreach (json_decode($res->getBody(), true) as $p) {
    echo $p['name'], ' โ€” $', $p['price'], PHP_EOL;
}

Output: total in collection: 30 and the three most expensive products. X-Total-Count is a real header, not a fixture. Exact-match filters (?category=tools), range operators (?price_gte=100), substring match (?name_like=river) and full-text ?q= all compose โ€” see the parameter reference. Note the trailing slash on base_uri and no leading slash on paths โ€” that's how Guzzle resolves relative URIs.

4. Writes are real

<?php
// CREATE โ€” the write actually persists (try that on JSONPlaceholder)
$res = $api->post('products', ['json' => [
    'name' => 'PHP Guide Widget', 'price' => 12.5, 'inStock' => true,
]]);
$created = json_decode($res->getBody(), true);   // 201, the stored record

// READ IT BACK
$back = json_decode($api->get("products/{$created['id']}")->getBody(), true);
// $back['name'] === 'PHP Guide Widget' โ€” it persisted

// UPDATE
$api->patch("products/{$created['id']}", ['json' => ['price' => 9.99]]);

// DELETE
$api->delete("products/{$created['id']}");
$status = $api->get("products/{$created['id']}", ['http_errors' => false])->getStatusCode();
// $status === 404

When we ran it: created id 31, read-back matched, price updated to 9.99, then 404 after delete. PUT/PATCH/DELETE behave like a real backend. (This is the part JSONPlaceholder, FakeStoreAPI and DummyJSON fake โ€” details with receipts in their respective guides.) Add ?mock_validate=1 to get real 422s with per-field errors when the body doesn't match the schema.

5. Sad paths: force any status, hit a real timeout

<?php
use GuzzleHttp\Client;
use GuzzleHttp\Exception\ConnectException;

$api = new Client([
    'base_uri' => 'https://mockbird.mockbird.workers.dev/m/demo/',
    'timeout'  => 1.5,   // your production client's real timeout
]);

// mock_delay=3000 โ†’ the endpoint takes 3s; your 1.5s timeout should fire.
try {
    $api->get('products', ['query' => ['mock_delay' => 3000]]);
} catch (ConnectException $e) {
    // "cURL error 28: Operation timed out after 1502 milliseconds"
    // this is the branch your app needs to handle gracefully
}

Ran it: cURL error 28 fired out of a genuinely slow socket read โ€” Guzzle wraps timeouts in ConnectException. That matters: MockHandler returns instantly, so your timeout configuration is never actually exercised. Any status on demand works the same way: ?mock_status=503 returns a real 503. QA can reproduce an error state by editing a URL, no code changes.

6. Exercise real retry logic with injected chaos

caseyamcl/guzzle_retry_middleware has real knobs (statuses, multiplier, Retry-After honoring โ€” which it obeys by default). Point it at an endpoint where ~40% of requests fail with a random 5xx/429 and watch it earn its keep:

<?php
use GuzzleHttp\Client;
use GuzzleHttp\HandlerStack;
use GuzzleRetry\GuzzleRetryMiddleware;

$stack = HandlerStack::create();
$stack->push(GuzzleRetryMiddleware::factory([
    'max_retry_attempts'       => 5,
    'retry_on_status'          => [429, 500, 502, 503, 504],
    'default_retry_multiplier' => 0.5,       // 0.5s, 1s, 1.5s ...
    'on_retry_callback'        => function ($attempt, $delay, $req, $opts, $res) {
        echo "  โ†ป retry #$attempt after ", $res ? $res->getStatusCode() : 'error', PHP_EOL;
    },
]));

$api = new Client([
    'base_uri' => 'https://mockbird.mockbird.workers.dev/m/demo/',
    'handler'  => $stack,
]);

for ($i = 1; $i <= 5; $i++) {
    $res = $api->get('products/1', ['query' => ['mock_chaos' => 0.4]]);
    echo "request $i => ", $res->getStatusCode(), PHP_EOL;
}

Our run: five final 200s, with the callback printing โ†ป retry #1 after 429, โ†ป retry #1 after 503โ€ฆ as the middleware absorbed each injected failure. mock_chaos=0.4 fails that fraction of requests with a random status from {500, 502, 503, 504, 429}; injected failures carry an x-mockbird-chaos: injected header, and chaos-failed writes are not applied โ€” safe to point retrying write logic at it too. For deterministic 429 testing (fixed window, real Retry-After and x-ratelimit-* headers), use ?mock_ratelimit=N.

7. Laravel's HTTP client

Laravel's Http facade is Guzzle underneath, with retry built in. This is the exact same class the facade resolves to, run standalone so you can verify it โ€” in a Laravel app just swap $http for Http:::

<?php
use Illuminate\Http\Client\Factory;

$http = new Factory;   // in Laravel: use Illuminate\Support\Facades\Http;

$response = $http
    ->baseUrl('https://mockbird.mockbird.workers.dev/m/demo')
    ->retry(3, 500)                       // Laravel's built-in retry vs mock_chaos
    ->get('/products', ['mock_chaos' => 0.4, 'limit' => 2]);

echo 'status: ', $response->status(), PHP_EOL;
echo 'first product: ', $response->json()[0]['name'], PHP_EOL;

Ran it: status: 200 despite the injected chaos โ€” retry(3, 500) did its job against real failures. In your app, keep the base URL in config (config('services.api.url') reading an .env entry) and the mock never appears in code, only in configuration:

# .env.local
API_URL=https://mockbird.mockbird.workers.dev/m/abc123
# .env.production
API_URL=https://api.yourcompany.com

8. PHPUnit: pin each test to a frozen snapshot

Shared mutable test data is how suites go flaky. Mockbird lets you save named snapshots of a project's entire dataset, then answer any GET from a snapshot โ€” read-only, live data untouched โ€” by sending one header. No setUp() resets, no ordering constraints. This file runs with zero setup because the public demo ships with built-in empty and edge-cases snapshots (100-char names, unicode, price 0, HTML-ish strings):

<?php
use PHPUnit\Framework\TestCase;
use GuzzleHttp\Client;

class ProductPageTest extends TestCase
{
    private function api(string $snapshot): Client
    {
        return new Client([
            'base_uri' => 'https://mockbird.mockbird.workers.dev/m/demo/',
            'headers'  => ['X-Mockbird-Snapshot' => $snapshot],
        ]);
    }

    public function testEmptyStateShowsNoProducts(): void
    {
        $products = json_decode($this->api('empty')->get('products')->getBody(), true);
        $this->assertSame([], $products);
    }

    public function testEdgeCasesRenderWithoutBreaking(): void
    {
        $products = json_decode($this->api('edge-cases')->get('products')->getBody(), true);
        $names = array_column($products, 'name');
        $this->assertContains('Free Sticker', $names);      // price: 0
        $this->assertNotEmpty(array_filter($names, fn ($n) => strlen($n) > 100)); // very long name
    }
}

We ran exactly this file before publishing: OK (2 tests, 3 assertions) in 0.36s. On your own project you define the scenarios once (baseline, empty, bug-repro-1234) with four curls and pin them the same way โ€” and because snapshot reads never mutate anything, paratest parallelism is safe. See the deterministic test data guide.

9. Honest comparison

The in-process tools are genuinely good, and for pure unit tests they're usually the better choice. The split:

Guzzle MockHandlerLaravel Http::fake()php-vcrMockbird
What it isCanned-response queue on one Guzzle clientFramework-level fake of the Http facadeRecords real traffic to cassettes, replaysHosted mock API
Works offline / zero latencyโœ”โœ”โœ” (after recording)โœ– (real network)
Catches accidental real HTTP in testsโœ–โœ” preventStrayRequests() โ€” a genuine superpowerโœ” (unrecorded โ†’ error)โœ– (it is real HTTP)
Reachable by teammates, frontend, CI, other servicesโœ– one client objectโœ– one processโœ– one processโœ” one https URL
Exists before the real API doesโœ” (hand-written responses)โœ” (hand-written)โœ– (needs traffic to record)โœ” (seeded or imported)
Real timeouts / retry-after / slow responsesโœ– instant (can only throw for you)โœ– instantโœ– instant replayโœ” mock_delay/mock_ratelimit
CRUD, filters, pagination, searchYou write every responseYou write every fakeOnly what was recordedBuilt in
State persists across processes/runsโœ–โœ–โœ–โœ” (+ snapshots)
Record real traffic & replayโœ–โœ–โœ” its superpowerPartial: HAR import + proxy record
Free tierfree (ships with Guzzle)free (ships with Laravel)free (package)free: 20 projects, 10k req/project/day

Use both: keep Http::fake()/MockHandler for millisecond-fast unit tests, and point integration tests, package READMEs, teaching materials and not-yet-built-backend work at a hosted URL.

10. Ship day

Mockbird follows plain REST conventions, so switching to the real backend is one base-URL change (an .env entry or config value). Hand your backend team the live contract while they build it: https://mockbird.mockbird.workers.dev/m/abc123/openapi.json, a Postman collection, or generated TypeScript types for the frontend half.

Also mocking for a frontend? The same project feeds React, Vue, Angular, Next.js, SvelteKit, React Native, Flutter and Android at once โ€” one dataset for the whole team. Python, Node.js, Go, Java, Ruby, Rust, Elixir or C#/.NET service in the mix too? Same drill. Docs โ†’
โšก Skip the terminal: this link creates a live, seeded backend (products, orders, customers, reviews) in the dashboard โ€” real URL, no signup. Or import your own OpenAPI spec, db.json, CSV, Postman collection, or HAR and mock your exact shapes.