lezhnev74 / psr-recording-middleware
A recording middleware for any PSR-7 HTTP client that records every request/response exchange and can replay them, turning recorded traffic into a predictable fixture for integration tests. Built on PSR-7, PSR-17 and PSR-3.
Package info
github.com/lezhnev74/psr-recording-middleware
pkg:composer/lezhnev74/psr-recording-middleware
Requires
- php: ^8.1
- lezhnev74/psr-logging-masking-middleware: ^1.1
- php-http/discovery: ^1.19
- psr/http-client: ^1.0
- psr/http-factory: ^1.0
- psr/http-message: ^1.1 || ^2.0
- psr/log: ^2.0 || ^3.0
Requires (Dev)
- colinodell/psr-testlogger: ^1.3
- friendsofphp/php-cs-fixer: ^3.64
- guzzlehttp/guzzle: ^7.0 || ^8.0
- guzzlehttp/psr7: ^2.0
- illuminate/http: ^10.0 || ^11.0 || ^12.0
- nyholm/psr7: ^1.8
- phpstan/phpstan: ^2.2
- phpunit/phpunit: ^10.5
- rector/rector: ^2.5
- roave/security-advisories: dev-latest
- wikimedia/composer-merge-plugin: ^2.1
Suggests
- psr/http-factory-implementation: A PSR-17 factory (e.g. guzzlehttp/psr7 or nyholm/psr7) so the middleware can rebuild recorded message bodies. Any HTTP app already ships one.
README
A recording middleware for any PSR-7 HTTP client that records every request and response passing through it, and can switch to replay mode to serve the recorded responses back - turning real HTTP traffic into a predictable fixture for integration tests.
It is built on PSR-7 (messages), PSR-17 (message factories) and PSR-3 (logging), so it stays implementation-agnostic: it works with any PSR-7 implementation, none of which is a hard dependency. Guzzle is the primary test target, not a runtime dependency.
Requirements
- PHP 8.1 - 8.5
- Any PSR-7 / PSR-17 implementation installed in your app (e.g.
guzzlehttp/psr7ornyholm/psr7) - discovered automatically via php-http/discovery.
Installation
composer require lezhnev74/psr-recording-middleware
Usage
Configure a run, open a named session, and attach it to your client - via the Guzzle-style handler middleware or the PSR-18 decorator:
use GuzzleHttp\Client; use GuzzleHttp\HandlerStack; use GuzzleHttp\Promise\Create; use Lezhnev74\PsrRecordingMiddleware\{HandlerMiddleware, Mode, RecordingClient, RecordingRun, RunConfig}; // The run's default mode seeds every session it opens. $run = RecordingRun::fromConfig(RunConfig::create('/path/to/store', Mode::Replay)); $session = $run->openSession('example.com'); // inherits Replay // Guzzle handler stack: $stack = HandlerStack::create(); $stack->push(HandlerMiddleware::for($session, Create::promiseFor(...))); $client = new Client(['handler' => $stack]); // ...or wrap any PSR-18 client: $client = new RecordingClient($psr18Client, $session); // Replay (the default): requests are served from the store, network untouched. // Requests are matched byte-for-byte, in recorded order; a mismatch throws. $client->get('https://example.com/'); // Mode lives on the session, so one session can be flipped without touching // the run's default or any sibling session - either seed it when opening: $recording = $run->openSession('example.com', Mode::Record); // ...or flip an already-open session (chains off openSession): $run->openSession('example.com')->setMode(Mode::Record); // A Record session hits the network and saves every turn to the store.
Full working examples live in the tests:
tests/KnownHostRecordReplayTest.php
(end-to-end record/replay with a real Guzzle client),
tests/HandlerMiddlewareTest.php and
tests/RecordingClientTest.php.
PHPUnit integration
Drive the mode from an env var, keep the fixture store in a committed
directory, and give each test its own session. Tests then need no
record/replay branching - run once with HTTP_RECORD=1 against the real
network, commit tests/fixtures/http/, and every ordinary run replays
offline. In your base TestCase:
protected function httpSession(): Session { $run = RecordingRun::fromConfig(RunConfig::create( __DIR__ . '/fixtures/http', getenv('HTTP_RECORD') !== false ? Mode::Record : Mode::Replay, $this->masker(), // see below )); // One fixture dir per test; session names may not contain "\". return $run->openSession(str_replace('\\', '.', static::class) . '.' . $this->name()); }
Attach the session to the client your code under test uses (handler stack or
RecordingClient, as above). For integration tests, do it at the DI seam,
e.g. in Laravel: $this->app->extend(ClientInterface::class, fn ($inner) => new RecordingClient($inner, $this->httpSession())).
To re-prove a single test still works against the live network without
re-recording the whole suite, flip only that test's session:
$this->httpSession()->setMode(Mode::Record).
Masking volatile fields
Replay matches requests byte-for-byte, so any field that changes between runs
(rotating tokens, a default User-Agent embedding PHP/Guzzle versions) breaks
committed fixtures - and secrets must not land in git. Mask them: masking
applies identically when recording and when matching, so a masked field is
stored as ***, never mismatches, and never leaks.
private function masker(): Masker { return RecordingMasker::create(MaskingConfig::create( ['Authorization', 'User-Agent'], // headers ['token'], // query args ['password'], // JSON/form body fields )); }
The full runnable pattern - env-driven mode, session per test, masking
proven against changed tokens and User-Agent - is
tests/PhpUnitIntegrationExampleTest.php.
Committing recordings to git
Recordings capture raw request/response bytes, including binary bodies. Tell git
to treat the store as binary so it never normalizes line endings or mangles a
payload, and to keep the noisy diffs out of code review. Add a .gitattributes
next to your fixture store:
tests/fixtures/http/** binary
binary implies -text -diff, so recordings are stored verbatim and shown as
Binary files differ rather than a line-by-line diff.
Local development
All QA tooling runs inside a PHP 8.5 Docker container (with Xdebug) via the
dev wrapper script - no PHP is needed on the host. Each command tunnels to the
matching Composer script inside the container, so
./dev <script> is just composer <script> run in Docker.
cp .env.dist .env # set DOCKER_USER_ID / DOCKER_GROUP_ID (see `id -u` / `id -g`) ./dev install # composer install ./dev test # run the test suite ./dev check # code style + static analysis + tests ./dev cs-fix # auto-fix code style ./dev debug-test # run tests under Xdebug (step-debugging)
Anything unrecognized is passed to composer, so ./dev require <pkg>,
./dev stan, ./dev rector etc. all work. Use ./dev php ... for a raw PHP
call, ./dev sh for a shell, and ./dev compose ... for raw docker compose.
Contributing
A few conventions, kept light:
- TDD. Every new branch needs a covering test; the recording/replay suite must stay green against both Guzzle and Nyholm PSR-7 impls.
- Conventional Commits (spec) for
messages, e.g.
feat(recording): ...,fix: ...,test: .... - Semantic versioning. Tags are
vMAJOR.MINOR.PATCH; the commit type drives the bump (fix→ patch,feat→ minor, a!/BREAKING CHANGE→ major). - Run
./dev check(style + static analysis + tests) before opening a PR.
License
MIT - see LICENSE.