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:
POST returns an id, then the record isn't there).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.
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.
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.
<?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.
<?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.
<?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.
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.
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
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.
The in-process tools are genuinely good, and for pure unit tests they're usually the better choice. The split:
| Guzzle MockHandler | Laravel Http::fake() | php-vcr | Mockbird | |
|---|---|---|---|---|
| What it is | Canned-response queue on one Guzzle client | Framework-level fake of the Http facade | Records real traffic to cassettes, replays | Hosted 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, search | You write every response | You write every fake | Only what was recorded | Built in |
| State persists across processes/runs | โ | โ | โ | โ (+ snapshots) |
| Record real traffic & replay | โ | โ | โ its superpower | Partial: HAR import + proxy record |
| Free tier | free (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.
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.