eyond/laravel-http-replay

Record and replay HTTP responses in your Laravel/Pest tests

Maintainers

Package info

github.com/EYOND/laravel-http-replay

Homepage

pkg:composer/eyond/laravel-http-replay

Transparency log

Fund package maintenance!

eyond

Statistics

Installs: 349

Dependents: 0

Suggesters: 0

Stars: 1

Open Issues: 3

v0.2.4 2026-08-11 09:56 UTC

README

Latest Version on Packagist GitHub Tests Action Status GitHub Code Style Action Status Total Downloads

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.

Installation

composer require eyond/laravel-http-replay --dev

Optionally publish the config file:

php artisan vendor:publish --tag="laravel-http-replay-config"

Quick Start

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

Choose a Replay Mode

Replay mode defines what repeated requests with the same replay key mean. Choose it deliberately whenever a test can send the same request more than once.

Sequential Mode (Current Default)

Use sequential() when repeated requests can return different states. Every call consumes one stored response in order and is recorded as the base filename, __2, __3, and so on.

Http::replay()
    ->for('api.example.com/orders/*')->sequential();

// The same GET can return the state before and after an update.
Http::get('https://api.example.com/orders/1');
Http::patch('https://api.example.com/orders/1', ['status' => 'fulfilled']);
Http::get('https://api.example.com/orders/1');

Sequential mode is appropriate for state transitions, polling, retries with changing responses, and tests that intentionally verify request cardinality. It remains the default for backward compatibility.

Reusable Mode

Use reusable() when the same replay key must always return the same response. One fixture is stored and can satisfy any number of matching requests.

Http::replay()
    ->for('akeneo.example.com/products/*')->reusable();

Http::get('https://akeneo.example.com/products/1');
Http::get('https://akeneo.example.com/products/1');
Http::get('https://akeneo.example.com/products/1');
// One replay file is stored.

Reusable mode compares status, stored headers, body, and body encoding while ignoring recording metadata such as recorded_at. During the initial recording run, every request occurrence still reaches the upstream service so Replay can verify that all responses are equivalent; unlimited reuse starts on subsequent runs. If recording or existing numbered fixtures contain different responses for the same reusable replay key, Replay fails and instructs you to use sequential() or distinct matchers. A partial fixture created by that recording is removed before failure, including when further concurrent responses arrive. Replay never silently replaces a stateful response.

Set a mode for the complete replay activation, override it for individual URL patterns, or configure a project default:

Http::replay()->reusable();

Http::replay()
    ->reusable()
    ->for('api.example.com/orders/*')->sequential();

Replay::configure()
    ->reusable()
    ->for('api.example.com/orders/*')->sequential();

Matchers define whether requests share a replay key; modes define how responses for that key are consumed. If requests are semantically different despite using the same endpoint, distinguish them with matchBy() or withAttributes() instead of relying on response order.

Usage

Basic Replay

it('fetches products', function () {
    Http::replay();

    $response = Http::get('https://api.example.com/products');

    expect($response->json('products'))->toHaveCount(10);
});

Same-URL Disambiguation (GraphQL etc.)

When multiple requests go to the same URL (e.g. GraphQL endpoints), you need to disambiguate them. There are several approaches:

Via withAttributes

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{...}}']);
});

Via matchBy with Body Hash

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{...}}']);
});

Via Closure Matcher

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'],
);

Composable Matchers

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
Canonical Body Hash canonical_body_hash 6-character hash after recursive JSON object-key sorting
Canonical Body Hash (paths) canonical_body_hash:variables,extensions.context Canonical hash of selected dot-notation paths
GraphQL Operation graphql_operation operationName, a named operation, or a deterministic fallback
Literal literal:shopify shopify
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']

body_hash, body_hash:<keys>, and query_hash retain their original non-canonical behavior so existing replay filenames remain unchanged. Canonicalization is only enabled through canonical_body_hash or its matcher object. Invalid JSON and JSON integers outside PHP's integer range fall back to a 6-character hash of the raw body. This avoids precision loss for large identifiers. For selected paths, a missing path is omitted from the canonical subset while an explicit null value is retained.

Matcher objects can be composed through the public factory or instantiated directly:

use EYOND\LaravelHttpReplay\Matchers;
use EYOND\LaravelHttpReplay\Matchers\CanonicalBodyHashMatcher;
use EYOND\LaravelHttpReplay\Matchers\GraphqlOperationMatcher;
use EYOND\LaravelHttpReplay\Matchers\LiteralMatcher;

Http::replay()->matchBy(
    Matchers::literal('custom'),
    Matchers::graphqlOperation(),
    Matchers::canonicalBodyHash('variables', 'extensions.context'),
);

// Equivalent building blocks are directly usable public API.
Http::replay()->matchBy(
    new LiteralMatcher('custom'),
    new GraphqlOperationMatcher,
    new CanonicalBodyHashMatcher(['variables', 'extensions.context']),
);

CanonicalJson sorts associative array and object keys recursively, preserves list order and scalar types, and encodes with explicit deterministic JSON flags and JSON_THROW_ON_ERROR.

Per-URL Configuration

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.

Global Configuration (Replay::configure())

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|NameMatcher ...$fields) Set global default matchers (overrides config file default)
withResponseHeaders(string ...$headers) Store all response headers with no arguments, or only the named headers
withoutResponseHeaders() Store no response headers
for(string $pattern)->matchBy(...) Set per-URL matchers
shopify(ShopifyProfile $profile = ShopifyProfile::Semantic) Register the opt-in Shopify GraphQL recipe

Per-test overrides in Http::replay() always take precedence over the corresponding Replay::configure() setting.

Response Headers

All response headers are stored by default for backward compatibility. Configure the project-wide behavior in config/http-replay.php with true for all headers, false for none, or an array for a case-insensitive allowlist:

'response_headers' => ['Content-Type', 'Content-Length', 'x-goog-hash'],

Use the same behavior globally for the test suite or override it for one replay activation:

Replay::configure()->withoutResponseHeaders();

Http::replay()->withResponseHeaders('Content-Type', 'x-goog-hash');
Http::replay()->withoutResponseHeaders();
Http::replay()->withResponseHeaders(); // Store all headers for this activation

When headers are disabled, replay files still contain "headers": [] to keep the schema stable.

Shopify GraphQL Profiles

Shopify matching is opt-in and uses the same public matcher composition as matchBy(). Configure the semantic default profile globally:

use EYOND\LaravelHttpReplay\Facades\Replay;

Replay::configure()->shopify();

It applies only to *.myshopify.com/admin/api/*/graphql.json and builds filenames from these exact parts:

  1. Literal shopify
  2. Literal graphql
  3. GraphQL operation name
  4. 6-character canonical hash of variables

For example, operation GetProducts with {"variables":{"a":1,"b":2}} becomes shopify_graphql_GetProducts_744ad5.json. Shop subdomain and Shopify API version are intentionally excluded.

Use the strict profile when changes to the GraphQL document must create a different replay:

use EYOND\LaravelHttpReplay\ShopifyProfile;

Replay::configure()->shopify(profile: ShopifyProfile::Strict);

Strict uses the same first three parts and hashes the complete canonical JSON body. Both document and variable changes therefore change the hash, while JSON object-key order does not. Anonymous operations use anonymous; unparseable input uses unknown; X-EYOND-Request is used as a fallback before those values.

Profiles are reusable recipes. A project-specific profile can compose the same matchers and override the global Shopify recipe per test:

use EYOND\LaravelHttpReplay\Matchers;
use EYOND\LaravelHttpReplay\ShopifyProfile;

Http::replay()
    ->for(ShopifyProfile::URL_PATTERN)->matchBy(
        Matchers::literal('ryzon'),
        Matchers::graphqlOperation(),
        Matchers::canonicalBodyHash('variables', 'extensions.context'),
    );

No Shopify profile is activated automatically. Existing projects continue using their configured matchers and replay filenames without changes.

Shared Fakes

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');

Use shared locations as fallbacks for test-local responses:

Http::replay()->fallbackTo('project-specific', 'package-defaults');

The current test-specific directory is checked first. Shared fallback locations are then checked in their declared order. The first location containing a response owns its complete queue, including __2, __3, and later responses; queues are never merged across locations. Missing responses are written to the test-specific directory unless writeTo() selects a different destination.

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
fallbackTo('a', 'b') test-specific, shared/a, shared/b (first wins per queue) 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}/.

Mix: Recorded + Static Fakes

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');
});

Renewal / Re-Recording

Fluent API

// 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()->useShared('shopify')->fresh();

// Delete test-local responses while preserving and revealing shared fallbacks
Http::replay()->fallbackTo('shopify')->fresh();

With fallbackTo(), fresh() only deletes responses from the current test-specific directory. Shared fallback locations are never deleted and remain available during the same activation. expireAfter() evaluates files in every source independently; when all responses for a queue are expired in a higher-priority source, Replay uses the next fallback containing a non-expired response.

Artisan Command

# 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

Pest Flag

# Re-record all fakes
vendor/bin/pest --replay-fresh

Environment Variable

REPLAY_FRESH=true vendor/bin/pest

Or set it in your app config:

// config/http-replay.php
'fresh' => env('REPLAY_FRESH', false),

Bail on CI

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/pest

You can also set it permanently in your config:

// config/http-replay.php
'bail' => env('REPLAY_BAIL', false),

Incomplete Test Marking

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.

Complex Scenario

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');
});

File Storage Format

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": {}
    }
}

UTF-8 text, XML, and JSON remain readable in replay files. Non-UTF-8 or otherwise binary response bodies are stored losslessly as Base64 with an explicit marker and decoded transparently during replay:

{
    "status": 200,
    "headers": [],
    "body": "AP+JUE5HDQoaCg==",
    "body_encoding": "base64"
}

Replay files without body_encoding, including files from earlier package versions, continue to load unchanged. XML is never parsed or reformatted.

Directory Structure

tests/.laravel-http-replay/
├── _shared/                                    # Shared fakes (via useShared/readFrom/fallbackTo/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')

Filename Conventions

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
Shopify semantic profile shopify_graphql_GetProducts_744ad5.json

In sequential mode, repeated requests with the same matcher filename form a queue. The first response is stored under the base filename, followed by __2, __3, and so on; replay consumes them in that order. In reusable mode, one stable response is stored and served repeatedly. The filename remains the lookup key in both modes.

Configuration

// 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'],

    // 'sequential' (current default) or 'reusable'
    'mode' => 'sequential',

    // true = all, false = none, array = only these names (case-insensitive)
    'response_headers' => true,

    // 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
];

API Reference

Http::replay()

Returns a ReplayBuilder instance with the following fluent methods:

Method Description
matchBy(string|Closure|NameMatcher ...$fields) Matchers for filename generation
reusable() Reuse one stable response for every request with the same replay key
sequential() Store and consume one ordered response per request
for(string $pattern) Configure per-URL matchers or replay mode through matchBy(), reusable(), or sequential()
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
fallbackTo(string ...$names) Load test-specific responses first, then shared fallbacks in order
writeTo(string $name) Save recorded fakes to a shared location
useShared(string $name) Read + write from a shared location
withResponseHeaders(string ...$headers) Store all response headers with no arguments, or only the named headers
withoutResponseHeaders() Store no response headers
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

Replay::configure()

Returns a ReplayConfig instance for global configuration without activating replay. Inherits into every Http::replay() call.

Method Description
matchBy(string|Closure|NameMatcher ...$fields) Set global default matchers
reusable() Make reusable mode the default for each Http::replay() call
sequential() Make sequential mode the default for each Http::replay() call
withResponseHeaders(string ...$headers) Store all response headers with no arguments, or only the named headers
withoutResponseHeaders() Store no response headers
for(string $pattern) Configure per-URL matchers or replay mode
shopify(ShopifyProfile $profile = ShopifyProfile::Semantic) Register the opt-in Shopify GraphQL recipe

Replay::getShared(string $path)

Load a single shared replay file for use in Http::fake(). Returns a PromiseInterface.

php artisan replay:prune

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

How It Works

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:

  1. Http::fake() with a callback — Laravel's HTTP client supports passing a closure to Http::fake(). This closure receives each outgoing request and can return a response or null (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.

  2. ResponseReceived event — 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.

Requirements

  • PHP 8.4+
  • Laravel 13
  • Pest PHP 4

Testing

composer test

Changelog

Please see CHANGELOG for more information on what has changed recently.

Contributing

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:

  1. Describes the problem or use case
  2. Includes a Claude Code prompt or a Claude Code plan (.md file) 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.

Security Vulnerabilities

Please review our security policy on how to report security vulnerabilities.

Credits

This package is an opinionated version of the original idea by Michael Ruf in laravel-http-automock.

License

The MIT License (MIT). Please see License File for more information.