cleatsquad / php-http-replay
Deterministic HTTP request recording and replay for PHP 8.2+
Requires
- php: >=8.2
- psr/http-client: ^1.0
- psr/http-factory: ^1.0
- psr/http-message: ^1.0 || ^2.0
Requires (Dev)
- guzzlehttp/guzzle: ^7.0 || ^8.0
- phpstan/phpstan: ^2.0
- phpunit/phpunit: ^11.0
- symfony/yaml: ^7.0 || ^8.0
Suggests
- guzzlehttp/guzzle: Allows integrating http-replay as a Guzzle HTTP handler/middleware
- symfony/yaml: Allows importing legacy YAML cassettes from PHP-VCR
Provides
None
Conflicts
None
Replaces
None
This package is not auto-updated.
Last update: 2026-08-27 07:24:19 UTC
README
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.
Architecture
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.
Installation
composer require --dev cleatsquad/php-http-replay
Requires 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.
Usage
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']]], ]);
Execution Modes
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.
Matching Modes & Strategies
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.
Matching
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.
Diagnostics & CLI Output
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"
Sanitization & JSON Path Redaction
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'], );
Storage Backends & Checksum Integrity
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.
Replay Audit Trail & Inspection
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.
Cassette Naming Strategies
Use CassetteNamingStrategyInterface for dynamic cassette resolution:
use CleatSquad\HttpReplay\Naming\CallbackCassetteNamingStrategy; $naming = new CallbackCassetteNamingStrategy(fn () => 'test_' . $testId);
Importing php-vcr cassettes
use CleatSquad\HttpReplay\PhpVcr\PhpVcrCassetteImporter; $cassette = PhpVcrCassetteImporter::fromYaml(__DIR__ . '/fixtures/legacy.yml');
Optional Community Integrations
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)
Limitations
- 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.
Public API
Everything under src/ that is public follows Semantic Versioning,
except src/Internal/, which is excluded and may change in any release.
Documentation
- ARCHITECTURE.md — components, and where a request is intercepted.
- docs/upstream/php-vcr/ — what of this library can be
contributed back to
php-vcr/php-vcr: the review and its decision matrix, the four proposals, and the evidence behind each. Proposal #1 is an open draft PR upstream.
Contributing
See CONTRIBUTING.md and UPGRADE.md. Bug reports and pull requests are welcome.
License
MIT. Copyright (c) 2026 Mohamed El Mrabet. See LICENSE.