Record and replay HTTP responses in your Laravel/Pest tests. Like snapshot testing, but for HTTP calls — responses are recorded on the first run and replayed automatically on subsequent runs.
composer require eyond/laravel-http-replay --devOptionally publish the config file:
php artisan vendor:publish --tag="laravel-http-replay-config"Add Http::replay() to your test. The first run makes real HTTP calls and stores the responses. Every subsequent run replays the stored responses — no network needed.
it('fetches products', function () {
Http::replay();
$products = app(ShopifyService::class)->getProducts();
expect($products)->toHaveCount(10);
});Stored responses are saved as JSON in tests/.laravel-http-replay/, organized by test file and test name:
tests/.laravel-http-replay/
└── Feature/
└── ShopifyTest/
└── it_fetches_products/
└── GET_shopify_com_api_products.json
it('fetches products', function () {
Http::replay();
$response = Http::get('https://api.example.com/products');
expect($response->json('products'))->toHaveCount(10);
});When multiple requests go to the same URL (e.g. GraphQL endpoints), you need to disambiguate them. There are several approaches:
The replay attribute is a reserved key that always takes priority over all matchers — no matchBy configuration needed:
it('fetches products and orders via GraphQL', function () {
Http::replay();
$products = Http::withAttributes(['replay' => 'products'])
->post('https://shopify.com/graphql', ['query' => '{products{...}}']);
$orders = Http::withAttributes(['replay' => 'orders'])
->post('https://shopify.com/graphql', ['query' => '{orders{...}}']);
});This stores the responses as products.json and orders.json.
For custom attribute keys, use matchBy('attribute:key'):
it('uses a custom attribute for naming', function () {
Http::replay()->matchBy('method', 'attribute:operation');
Http::withAttributes(['operation' => 'getProducts'])
->post('https://shopify.com/graphql', ['query' => '{products{...}}']);
});Automatically distinguish requests by including the request body hash in the filename:
it('auto-disambiguates by body', function () {
Http::replay()->matchBy('url', 'body_hash');
Http::post('https://shopify.com/graphql', ['query' => '{products{...}}']);
Http::post('https://shopify.com/graphql', ['query' => '{orders{...}}']);
});Use a closure for custom filename generation. The closure may return a string, int, array, or Collection — multiple parts are joined with _, empty parts are filtered out:
Http::replay()->matchBy(
'method',
fn(Request $r) => $r->data()['operationName'] ?? 'unknown',
);
// Or return multiple parts as array or Collection:
Http::replay()->matchBy(
fn(Request $r) => ['graphql', $r->data()['operationName'] ?? 'unknown'],
);The matchBy() method accepts any combination of built-in matchers:
| Matcher | Config String | Alias | Example Output |
|---|---|---|---|
| HTTP Method | method |
http_method |
GET |
| URL (host + path) | url |
shop_myshopify_com_api_products |
|
| Host only | host |
shop_myshopify_com |
|
| Domain (host without subdomain) | domain |
myshopify_com |
|
| Subdomain | subdomain |
shop |
|
| Path only | path |
api/v1/products |
|
| HTTP Attribute | attribute:key |
http_attribute:key |
Value of $request->attributes()['key'] |
| Body Hash | body_hash |
a1b2c3 (6-char hash of entire body) |
|
| Body Hash (keys) | body_hash:query,variables.id |
Hash of specific body fields | |
| Body Field | body_field:path |
Value of JSON body field (dot notation) | |
| Query Hash | query_hash |
a1b2c3 (6-char hash of all query params) |
|
| Query Hash (keys) | query_hash:page,limit |
Hash of specific query params | |
| Query Param | query:key |
Value of a specific query parameter | |
| Header | header:key |
Value of a specific request header | |
| Closure | fn(\Illuminate\Http\Client\Request $r) => ... |
Returns string, int, array, or Collection |
Default: ['method', 'url']
Configure different matchers for different URL patterns:
Http::replay()
->for('myshopify.com/*')->matchBy('url', 'attribute:request_name')
->for('reybex.com/*')->matchBy('method', 'url');The for() method returns a proxy object — you must call matchBy() directly on it. This prevents accidental state leaks.
Use Replay::configure() to set up matchers globally (e.g. in tests/Pest.php) without activating replay. This stores configuration only — no fake callback or event listener is registered. When Http::replay() is called in a test, it inherits the stored config automatically.
// tests/Pest.php — configures, does NOT activate
use EYOND\LaravelHttpReplay\Facades\Replay;
Replay::configure()
->for('myshopify.com/*')->matchBy('url', 'attribute:request_name')
->for('reybex.com/*')->matchBy('method', 'url');// Test — activates and inherits config
it('replays shopify', function () {
Http::replay();
app(ShopifyService::class)->getProducts();
});
// Test — overrides config for this test
it('special test', function () {
Http::replay()
->for('myshopify.com/*')->matchBy('method', 'url');
// Uses method + url instead of url + attribute:request_name
});Replay::configure() supports:
| Method | Description |
|---|---|
matchBy(string|Closure ...$fields) |
Set global default matchers (overrides config file default) |
for(string $pattern)->matchBy(...) |
Set per-URL matchers |
Per-test overrides in Http::replay() always take precedence over Replay::configure() for the same pattern.
Record responses once and reuse them across multiple tests.
Record to a shared location (read + write):
it('records shared shopify fakes', function () {
Http::replay()->useShared('shopify');
app(ShopifyService::class)->getProducts();
});Read from shared, write to test-local:
it('uses shared shopify fakes', function () {
Http::replay()->readFrom('shopify');
$products = app(ShopifyService::class)->getProducts();
expect($products)->toHaveCount(10);
});Read from multiple shared locations (first wins):
Http::replay()->readFrom('shopify', 'shopify-fallback');Write to shared, read from test-local:
Http::replay()->writeTo('shopify');Combine read + write explicitly:
Http::replay()->readFrom('shopify')->writeTo('shopify-v2');Use shared fakes for an entire file:
beforeEach(function () {
Http::replay()->readFrom('shopify');
});
it('test one', function () {
// Uses shared shopify fakes
});
it('test two', function () {
// Uses shared shopify fakes
});| Method | Reads from | Writes to |
|---|---|---|
readFrom('a', 'b') |
shared/a, shared/b (first wins) | test-specific |
writeTo('x') |
test-specific | shared/x |
useShared('name') |
shared/name | shared/name |
readFrom('a')->writeTo('x') |
shared/a | shared/x |
Load a single shared fake in Http::fake():
use EYOND\LaravelHttpReplay\Facades\Replay;
Http::fake([
'foo.com/posts/*' => Replay::getShared('fresh-test/GET_jsonplaceholder_typicode_com_posts_3.json'),
]);Shared fakes are stored in tests/.laravel-http-replay/_shared/{name}/.
Combine replay recording with static Http::fake() stubs. Use only() to limit which URLs are recorded:
it('mixes recorded and static fakes', function () {
Http::replay()
->only(['shopify.com/*'])
->alsoFake([
'api.stripe.com/*' => Http::response(['ok' => true]),
'sentry.io/*' => Http::response([], 200),
]);
// Shopify calls are recorded/replayed
$products = Http::get('https://shopify.com/api/products');
// Stripe and Sentry use static fakes
$charge = Http::get('https://api.stripe.com/charges');
});// Re-record everything for this test
Http::replay()->fresh();
// Re-record only matching URLs
Http::replay()->fresh('shopify.com/*');
// Auto-expire after 7 days (re-records expired responses)
Http::replay()->expireAfter(days: 7);
// Auto-expire after 1 month (accepts DateInterval)
Http::replay()->expireAfter(new DateInterval('P1M'));
// Re-record shared fakes
Http::replay()->readFrom('shopify')->fresh();# Delete all stored replays
php artisan replay:prune
# Delete replays for a specific test
php artisan replay:prune --test="it fetches products"
# Delete replays for a specific test file
php artisan replay:prune --file=tests/Feature/ShopifyTest.php
# Delete replays matching a URL pattern
php artisan replay:prune --url="shopify.com/*"
# Delete specific shared fakes
php artisan replay:prune --shared=shopify# Re-record all fakes
vendor/bin/pest --replay-freshREPLAY_FRESH=true vendor/bin/pestOr set it in your app config:
// config/http-replay.php
'fresh' => env('REPLAY_FRESH', false),Prevent tests from accidentally recording new fakes in CI by enabling bail mode. When active, tests will fail if Replay attempts to write a new file.
// Per-test or in beforeEach
Http::replay()->bail();
// Per-test with other options
Http::replay()->readFrom('shopify')->bail();# Pest flag (recommended for CI)
vendor/bin/pest --replay-bail
# Or via environment variable
REPLAY_BAIL=true vendor/bin/pestYou can also set it permanently in your config:
// config/http-replay.php
'bail' => env('REPLAY_BAIL', false),When Replay records a new response during a test, the test is automatically marked as incomplete (yellow) — just like Pest's snapshot testing. This makes it clear which tests recorded new data and need a re-run to verify.
it('complex shopify sync', function () {
Http::replay()
->only(['shopify.com/*'])
->for('shopify.com/graphql')->matchBy('url', 'body_hash')
->expireAfter(days: 7)
->alsoFake([
'api.stripe.com/*' => Http::response(['ok' => true]),
]);
$products = Http::withAttributes(['replay' => 'products'])
->post('https://shopify.com/graphql', ['query' => '{products{...}}']);
$charge = Http::get('https://api.stripe.com/charges');
expect($products->json())->toHaveKey('data.products');
});Each stored response is a JSON file containing the response data and metadata:
{
"status": 200,
"headers": {
"Content-Type": ["application/json"]
},
"body": {
"products": []
},
"recorded_at": "2026-02-12T14:30:00+00:00",
"request": {
"method": "GET",
"url": "https://shopify.com/api/products",
"attributes": {}
}
}tests/.laravel-http-replay/
├── _shared/ # Shared fakes (via useShared/readFrom/writeTo)
│ └── shopify/
│ └── GET_shopify_com_api_products.json
├── Feature/
│ └── ShopifyTest/
│ └── it_fetches_products/ # Auto-named from Pest test
│ ├── GET_shopify_com_api_products.json
│ ├── products.json # Via withAttributes(['replay' => 'products'])
│ └── POST_shopify_com_graphql_a1b2c3.json # Via matchBy('url', 'body_hash')
| Scenario | Filename |
|---|---|
| Default | GET_api_example_com_products.json |
withAttributes(['replay' => 'products']) |
products.json |
matchBy('url', 'body_hash') |
shopify_com_graphql_a1b2c3.json |
| Duplicate URL (sequential calls) | GET_api_example_com_products__2.json |
// config/http-replay.php
return [
// Directory for stored replays
// Relative paths are resolved from base_path() (your project root)
// Absolute paths (starting with /) are used as-is
'storage_path' => 'tests/.laravel-http-replay',
// Default matchers for filename generation
// Short forms: 'method', 'attribute:key'
// Aliases: 'http_method', 'http_attribute:key'
'match_by' => ['method', 'url'],
// Auto-expire after N days (null = never)
'expire_after' => null,
// Force re-recording of all replays
'fresh' => false, // Use env('REPLAY_FRESH', false) in your app
// Fail tests if Replay attempts to write
'bail' => false, // Use env('REPLAY_BAIL', false) in your app
];Returns a ReplayBuilder instance with the following fluent methods:
| Method | Description |
|---|---|
matchBy(string|Closure ...$fields) |
Matchers for filename generation |
for(string $pattern) |
Set URL pattern for per-URL matcher config (returns proxy, must chain matchBy()) |
only(array $patterns) |
Only record/replay URLs matching these patterns |
alsoFake(array $stubs) |
Additional static fakes for non-replayed URLs |
readFrom(string ...$names) |
Load stored fakes from shared location(s), first wins |
writeTo(string $name) |
Save recorded fakes to a shared location |
useShared(string $name) |
Read + write from a shared location |
fresh(?string $pattern) |
Delete stored fakes and re-record (optionally filtered by URL pattern) |
bail() |
Fail if Replay attempts to record a new fake (no stored response found) |
expireAfter(int|DateInterval $days) |
Auto-expire stored fakes after N days or a DateInterval |
Returns a ReplayConfig instance for global configuration without activating replay. Inherits into every Http::replay() call.
| Method | Description |
|---|---|
matchBy(string|Closure ...$fields) |
Set global default matchers |
for(string $pattern) |
Set per-URL matchers (returns proxy, must chain matchBy()) |
Load a single shared replay file for use in Http::fake(). Returns a PromiseInterface.
| Option | Description |
|---|---|
--test="name" |
Delete fakes for a specific test description |
--file=path |
Delete fakes for a specific test file |
--url="pattern" |
Delete fakes matching a URL pattern |
--shared=name |
Delete shared fakes by name |
| (no options) | Delete all stored replays |
This package uses only public Laravel APIs — no internal hacks, no monkey-patching, no overriding core classes. Everything is built on top of two official extension points:
-
Http::fake()with a callback — Laravel's HTTP client supports passing a closure toHttp::fake(). This closure receives each outgoing request and can return a response ornull(to allow the real request). Http Replay registers a single callback that checks for stored responses and either serves them or lets the request through. -
ResponseReceivedevent — Laravel dispatches this event after every HTTP response. Http Replay listens for it to capture real responses and save them to disk.
The flow:
Http::replay()
│
├─ Registers Http::fake(callback) via Factory::macro()
└─ Registers ResponseReceived event listener
Request comes in:
│
├─ Stored response exists? → Return it (no network call)
└─ No stored response? → Return null → Real HTTP call happens
│
└─ ResponseReceived event fires
→ Serialize & store to disk
The Http::replay() macro itself is registered on Illuminate\Http\Client\Factory via Laravel's standard macro() method in the service provider. No classes are extended or replaced.
- PHP 8.4+
- Laravel 13
- Pest PHP 4
composer testPlease see CHANGELOG for more information on what has changed recently.
This package is built with Vibe Coding — designed and developed in collaboration with Claude Code. Despite that, the codebase follows strict quality standards: PHPStan level 5, full test coverage across PHP 8.4-8.5 and Laravel 13, and consistent code formatting via Pint.
Bug fixes — PRs with a failing test and fix are welcome.
New features — Please don't submit a traditional code PR. Instead, open an issue or PR that:
- Describes the problem or use case
- Includes a Claude Code prompt or a Claude Code plan (
.mdfile) that I can use to implement the feature myself
This keeps the codebase consistent and lets me iterate on the implementation with the same AI-assisted workflow used to build the package.
Please review our security policy on how to report security vulnerabilities.
This package is an opinionated version of the original idea by Michael Ruf in laravel-http-automock.
- Patrick Korber
- Michael Ruf — original idea
- All Contributors
The MIT License (MIT). Please see License File for more information.