Deterministic HTTP request recording and replay for PHP built on PSR-7 and PSR-18.
A test that calls a real API is slow, costs money, and fails for reasons that have nothing to do with your code. Record the exchange once, replay it forever after: the same request returns the same response, offline, in milliseconds. When the request no longer matches what was recorded, you get told exactly which field differed — not just that something did.
php-http-replay separates the replay pipeline into four independent layers:
- Interception (
HttpReplayEngine): PSR-18 client orchestrating replay, record, record-once, and passthrough modes. - Selection (
ExchangeSelectorInterface): Pluggable exchange selection strategies (SequentialExchangeSelector,UnorderedExchangeSelector). - Matching (
RequestMatcherInterface): Semantic JSON and HTTP request comparison producing detailedMatchResultdiagnostics. - Cassette Format v2: Versioned schema (
version: 2) with SHA-256 checksum integrity and backward-compatible loading of legacy v1 cassettes.
composer require --dev cleatsquad/php-http-replayRequires PHP 8.2 or later. The core depends on PSR interfaces only
(psr/http-message, psr/http-client, psr/http-factory); Guzzle and
Symfony YAML are optional and used only by the integration and import layers.
See UPGRADE.md for upgrade notes and compatibility guides between major/minor versions.
use CleatSquad\HttpReplay\Engine\HttpReplayEngine;
use CleatSquad\HttpReplay\Enum\ExecutionMode;
use CleatSquad\HttpReplay\Integration\Guzzle\GuzzleReplayHandler;
use CleatSquad\HttpReplay\Matcher\DefaultRequestMatcher;
use CleatSquad\HttpReplay\Sanitizer\DefaultSanitizer;
use CleatSquad\HttpReplay\Storage\JsonCassetteStore;
use GuzzleHttp\Client;
use GuzzleHttp\HandlerStack;
$engine = new HttpReplayEngine(
ExecutionMode::Replay,
new JsonCassetteStore(__DIR__ . '/fixtures/cassettes'),
'openai_chat_cassette',
new DefaultRequestMatcher(),
new DefaultSanitizer()
);
$client = new Client(['handler' => HandlerStack::create(new GuzzleReplayHandler($engine))]);
// No network call: the recorded PSR-7 response is returned as is.
$response = $client->post('https://api.openai.com/v1/chat/completions', [
'json' => ['model' => 'gpt-4o', 'messages' => [['role' => 'user', 'content' => 'Hello']]],
]);ExecutionMode::Replay: Performs no network calls and replays recorded responses from the cassette.ExecutionMode::Record: Sends real HTTP requests, sanitizes the response, appends the exchange to the cassette, and returns the response.ExecutionMode::RecordOnce: Replays if a matching exchange exists in the cassette; executes real network call, sanitizes, and appends to cassette if missing.ExecutionMode::Passthrough: Bypasses the replay engine and performs live HTTP calls without modifying cassettes.
By default, requests are matched sequentially (ExecutionMatchingMode::Sequential). You can configure non-sequential, out-of-order matching via ExecutionMatchingMode::Unordered (OPT-IN):
use CleatSquad\HttpReplay\Enum\ExecutionMatchingMode;
$engine = new HttpReplayEngine(
ExecutionMode::Replay,
$cassetteStore,
'cassette_name',
$requestMatcher,
$sanitizer,
matchingMode: ExecutionMatchingMode::Unordered,
);ExecutionMatchingMode::Sequential(Default): Requests must match cassette exchanges in exact sequential order. Preserves deterministic response order for repeated identical requests.ExecutionMatchingMode::Unordered(OPT-IN): Requests can match any available, unconsumed exchange in the cassette. Useful for asynchronous, parallel, or non-deterministic test runners where request dispatch order varies. Once consumed, an exchange cannot be replayed.
Performance & Trade-offs: In
Sequentialmode, matching is$O(1)$ per request. InUnorderedmode, matching is up to$O(N)$ per request where$N$ is the number of exchanges in the cassette. For large cassettes ($N > 1000$ ),Sequentialmode is strongly recommended.
DefaultRequestMatcher compares method, URI, headers and body. JSON bodies are
compared semantically: object key order is ignored, array element order is
significant, and scalar types are compared strictly, so 1 does not match
"1". Query parameters are compared as a set.
When nothing matches, RequestMismatchException (or UnorderedMismatchException in unordered mode) names the failing path, for
instance body.messages.0.content, along with the expected and actual values.
Difference, MatchResult, and RequestMismatchException offer multi-line CLI diagnostic output:
try {
$engine->sendRequest($request);
} catch (RequestMismatchException $e) {
echo $e->toCliString(colorize: true);
}Output:
Request mismatch in cassette "openai_chat" at index 0:
body.messages.0.content: expected "Hello", actual "Bonjour"
DefaultSanitizer redacts secrets in headers, URI query parameters, recursively in JSON bodies by key name, and by explicit JSON paths/pointers (e.g., $.user.profile.token or payment.card.number):
$sanitizer = new DefaultSanitizer(
sensitiveHeaders: ['authorization', 'x-api-key'],
sensitiveBodyKeys: ['api_key', 'token', 'secret'],
sensitiveQueryParams: ['api_key', 'token'],
replacement: '[REDACTED]',
sensitiveJsonPaths: ['$.user.profile.token', 'payment.card.number'],
);JsonCassetteStore: Writes UTF-8 JSON atomically through a temporary file andLOCK_EXrename, stamped with a schema version and automatic SHA-256 integrity checksum (sha256:<hash>).InMemoryCassetteStore: RAM-only cassette store for fast, zero-I/O unit tests.
HttpReplayEngine::stats() returns a ReplayStats snapshot to inspect cassette consumption:
$stats = $engine->stats();
echo "Replayed: {$stats->replayedCount}/{$stats->totalExchanges}\n";
if ($stats->hasUnusedExchanges()) {
echo "Unused exchange indices: " . implode(', ', $stats->unusedIndices);
}
if ($stats->isFullyConsumed()) {
echo "All cassette exchanges were executed successfully.";
}unusedIndices lists the exchanges the cassette already held and that were never
replayed, which is what a stale cassette looks like. An exchange recorded during
the session by Record or RecordOnce is never counted as unused.
Use CassetteNamingStrategyInterface for dynamic cassette resolution:
use CleatSquad\HttpReplay\Naming\CallbackCassetteNamingStrategy;
$naming = new CallbackCassetteNamingStrategy(fn () => 'test_' . $testId);use CleatSquad\HttpReplay\PhpVcr\PhpVcrCassetteImporter;
$cassette = PhpVcrCassetteImporter::fromYaml(__DIR__ . '/fixtures/legacy.yml');php-http-replay is fully autonomous and zero-dependency. Community integration packages can be installed separately:
cleatsquad/php-http-replay-vcr(External adapter for legacy PHP-VCR storage)cleatsquad/php-http-replay-vcr-plugin(External adapter for HTTPlug pipeline)cleatsquad/php-http-replay-phpunit(Tooling package for PHPUnit 11#[Cassette]attributes)
- Asynchronous execution:
GuzzleReplayHandlerreturns Guzzle promises, but the underlying replay and record paths are synchronous (PSR-18sendRequest), so promises settle on invocation. True event-loop streaming is out of scope. - Streaming and SSE: transport-level timing and chunk boundaries for Server-Sent Events are not reproduced. Stream bodies remain readable in full through PSR-7.
Everything under src/ that is public follows Semantic Versioning,
except src/Internal/, which is excluded and may change in any release.
See CONTRIBUTING.md and UPGRADE.md. Bug reports and pull requests are welcome.
MIT. Copyright (c) 2026 Mohamed El Mrabet. See LICENSE.