garethp/http-playback
Record real HTTP responses once, then replay them deterministically in your PHP tests. GarethP's HTTP Playback makes it easy to capture and reuse external API interactions, keeping test suites fast, reliable, and free of network flakiness.
Install (dev-only — you never want this in production):
composer require --dev garethp/http-playback
Version to pin: v2.0 is the current release and is the one you want — it moves the package to Guzzle 7 and adds PHP 8.x support, dropping PHP 5.x. If you're still on Guzzle 6 (common when another dependency, including older Laravel releases, has it locked), v2.0 will not install; you'll get a Composer conflict and have to either upgrade Guzzle or stay on the 1.x line, which is now the unsupported branch. Check composer why guzzlehttp/guzzle before you start.
Prerequisites: PHP 7.x/8.x (8.x only from v2.0) and Guzzle 7. The package is a thin layer over Guzzle's handler stack — it records real HTTP traffic to disk and replays it later. If your code doesn't make requests through Guzzle, this package can't help you.
Where to look first: the README usage example, then src/ — the interesting bits are the recording middleware (writes fixtures) and the playback handler (reads them). Everything else is a filesystem wrapper.
Minimal flow:
tests/fixtures/http.use Garethp\HttpPlayback\HttpPlayback;
$playback = new HttpPlayback(__DIR__ . '/fixtures/http');
// Step 1 — record once, against the real API
$client = $playback->record()->getClient();
$client->get('https://api.example.com/v1/users');
// Step 2 — from now on (locally and in CI), replay offline
$client = $playback->playback()->getClient();
$response = $client->get('https://api.example.com/v1/users');
The exact mode API (chainable record()/playback() vs. separate Record/Playback classes) has shifted across versions — confirm against the README for the tag you pull. Everything below is independent of that detail.
First use case: an integration test or artisan command that hits a flaky third-party API. Record a happy path plus one or two error paths, commit the fixtures, and the test suite stops depending on the network, tokens, or rate limits.
Upgrade deliberately, then re-record. v2.0 is a hard major bump with no deprecation path: Guzzle 6 is gone and the handler stack it patches is Guzzle 7's. The release notes say nothing about fixture format, so assume the on-disk files still parse but that matching may drift — the safest sequence is bump the constraint, run the existing playback suite, and re-record any scenario that misses. Do the bump as its own commit so a fixture churn diff doesn't hide inside a feature PR.
Swap the client at the boundary, not the call sites. Build your API service around an injected GuzzleHttp\ClientInterface. In production you bind a normal client; in tests you bind the playback client. That single seam means no test ever needs to know HttpPlayback exists.
// AppServiceProvider
$this->app->bind(AcmeApi::class, function () {
$client = app()->environment('testing')
? (new HttpPlayback(base_path('tests/fixtures/http')))->playback()->getClient()
: new \GuzzleHttp\Client(['base_uri' => config('services.acme.url')]);
return new AcmeApi($client);
});
Record deliberately, replay always. Never let record mode be the default. Add a dedicated command (php artisan http:record acme) or a Composer script that flips into record mode, runs a known-good scenario, and exits. Normal test runs are playback-only. This keeps fixtures intentional and diffs reviewable.
Organise fixtures per scenario, not per endpoint. One directory per feature or test class:
tests/fixtures/http/
checkout_success/
checkout_card_declined/
checkout_timeout/
Since the store is a plain directory, you can point a test at a specific scenario by constructing the playback object with that path. Much easier to reason about than one giant flat dump.
Treat fixtures as code. They get committed, reviewed in PRs, and refreshed on a schedule (or when the upstream contract changes). A composer record script that regenerates them all and a CI job that runs it monthly against staging is a cheap contract test.
Combine with your normal assertions. Playback returns a real Response object, so your existing assertions (assertStatus, json(), getBody()) work unchanged. The only difference is latency and determinism.
Test error paths cheaply. Because you can hand-write or record a 429/500/timeout fixture, this is the easiest way to exercise retry and backoff logic without a fault-injection proxy.
public function test_retries_on_429(): void
{
$client = (new HttpPlayback(__DIR__ . '/../fixtures/http/rate_limited'))->playback()->getClient();
$api = new AcmeApi($client);
$this->expectNotToPerformAssertions();
$api->fetchUsers(); // must retry internally and eventually succeed
}
User-Agent). If your matcher considers headers, or your recorded fixtures were captured under Guzzle 6, expect a wave of misses immediately after moving to v2.0. Re-record rather than hand-patching keys.Authorization, Set-Cookie, API keys, and PII all get serialised verbatim. Add a sanitising step (or a .gitattributes/grep check in CI) before the first commit. Treat a fixture directory as you would a snapshot of production data.HTTP_PLAYBACK_RECORD=1) and assert in CI that recording is off. A misconfigured pipeline that quietly re-records will overwrite good fixtures with error pages.Http facade is Guzzle underneath but you can't inject a client through it directly. Either drop to Http::withHandler(...)/buildClient() to install the playback handler, or refactor that call site onto an injected Guzzle client. Don't assume the facade picks this up automatically. On a modern Laravel (Guzzle 7 under the hood) the facade and v2.0 are finally on the same major, so withHandler is more likely to work than it was against 1.x.Pool/promise concurrency are areas to test manually before relying on this anywhere important.How can I help you explore Laravel packages today?