mtk3d / http-vcr
Record and replay HTTP requests in PHP tests — a decorator over any PSR-18 client, so your suite runs fast and deterministic with no network access and no API keys. Built on PSR-7/PSR-17/PSR-18.
Requires
- php: ^8.2
- nikic/php-parser: ^5.0
- psr/clock: ^1.0
- psr/http-client: ^1.0
- psr/http-factory: ^1.0
- psr/http-message: ^2.0
- symfony/console: ^6.4 || ^7.0 || ^8.0
Requires (Dev)
- guzzlehttp/guzzle: ^7.0
- laminas/laminas-diactoros: ^3.0
- laravel/pint: ^1.30
- nyholm/psr7: ^1.8
- phpstan/phpstan: ^2.0
- phpunit/phpunit: ^11.5 || ^12.0 || ^13.0
- symfony/http-client: ^7.0 || ^8.0
- symfony/yaml: ^6.4 || ^7.0 || ^8.0
Suggests
- guzzlehttp/guzzle: To use the Guzzle HandlerStack middleware bridge (Bridge/Guzzle/VcrMiddleware) — plain GuzzleHttp\Client already works via PSR-18 without this
- mtk3d/laravel-http-vcr: Zero-setup integration for a Laravel app — auto-registered service provider, Http facade interception, vcr:* artisan commands. Requires Laravel 11+
- nyholm/psr7: A PSR-17 factory implementation, used to rebuild replayed responses — any PSR-17 implementation works (guzzlehttp/psr7 and laminas/laminas-diactoros are detected too)
- phpunit/phpunit: ^10.0 || ^11.0 || ^12.0 || ^13.0 — to use the #[UseCassette] attribute and the InteractsWithCassettes trait (Bridge/PHPUnit); the attribute needs the Extension API, added in PHPUnit 10
- symfony/http-client: To use the native Symfony HttpClientInterface bridge (Bridge/Symfony/VcrHttpClient) — Psr18Client already works via PSR-18 without this
- symfony/yaml: To use the YAML cassette serializer instead of the default JSON one
This package is auto-updated.
Last update: 2026-08-23 19:59:46 UTC
README
Record real HTTP interactions once, replay them on every test run after that.
The PHP test suite for "the code that talks to Shopify/Stripe/Zendesk" becomes fast, deterministic, and runnable in CI with no network access and no API credentials.
http-vcr is a decorator over PSR-18
(Psr\Http\Client\ClientInterface). Anything that already speaks PSR-18 (Guzzle 7+,
Symfony's Psr18Client, php-http, Buzz) works unchanged, and two VcrClient instances in
one process never interfere — nothing outside the instance you construct is touched.
$vcr = new VcrClient($realClient, cassette: 'shopify/get-product'); $response = $vcr->sendRequest($request); // first run: the real request happens, and is recorded to // tests/Cassettes/shopify/get-product.json // every run after: no network call — the recorded response is replayed
Recording works with no setup on a developer machine, and is refused on CI, so a missing cassette fails loudly instead of quietly reaching for a real API without credentials.
Contents
- Installation
- Quick start
- Without PHPUnit
- What lands on disk
- Record modes
- Re-recording
- Matching
- Redaction and secret scanning
- More control
- Framework integration
- CLI
- Configuration
- Documentation
- Dependencies
- Development
- License
Installation
composer require --dev mtk3d/http-vcr
No release is tagged yet. The package is on Packagist, so until one is, ask for the development branch:
composer require --dev mtk3d/http-vcr:dev-master
You need PHP 8.2+ and an HTTP client to wrap — whichever one the project already uses. To
rebuild a replayed response http-vcr also needs a PSR-17 factory, and it takes that from
the client library you already have (Guzzle ships one, Symfony's client pulls one in). If
nothing usable is found it stops before the first request rather than partway through one,
and composer require --dev nyholm/psr7 settles it.
Quick start
One line in phpunit.xml registers the extension behind #[UseCassette]. PHPUnit has no
auto-discovery for extensions, so this is the one thing http-vcr can't do for you:
<extensions> <bootstrap class="HttpVcr\Bridge\PHPUnit\Extension"/> </extensions>
That's the whole setup — no bootstrap code, no config file, no cassette directory to create. Then put the attribute on the test and take the client from the trait:
use HttpVcr\Bridge\PHPUnit\InteractsWithCassettes; use HttpVcr\Bridge\PHPUnit\UseCassette; final class ShopifyClientTest extends TestCase { use InteractsWithCassettes; #[UseCassette('shopify/get-product')] public function testGetProduct(): void { $shopify = new ShopifyClient($this->vcrClient(), new GuzzleHttp\Psr7\HttpFactory()); $product = $shopify->getProduct('123'); $this->assertSame('T-Shirt', $product['title']); } }
$this->vcrClient() is a Psr\Http\Client\ClientInterface, so it drops into the exact
spot the real client occupied — no interface changes, no test-only branch in the code under
test.
First run — with a real key available, the call goes over the wire and is written to
tests/Cassettes/shopify/get-product.json:
SHOPIFY_API_KEY=sk_live_xxx vendor/bin/phpunit --filter testGetProduct
Every run after — the cassette exists, so the same test replays it. No network, no API key, no flakiness:
vendor/bin/phpunit
Commit the cassette next to the test. Credentials in Authorization and Cookie headers
were replaced with placeholders before it hit disk.
Every field of the attribute is a VcrClient constructor parameter under the same name:
#[UseCassette(
'shopify/checkout',
mode: RecordMode::ExtendCassette,
strictMode: StrictMode::InOrder,
staleAfter: new DateInterval('P7D'),
requiresEnv: ['SHOPIFY_API_KEY'],
locked: true,
)]
On a class it applies to every test method in it; a method-level attribute replaces a class-level one outright rather than merging with it.
Without PHPUnit
The attribute is a convenience over an ordinary object. In a script, or under a different test framework, construct it directly — everything the attribute sets is a constructor argument or a method on this object:
use HttpVcr\VcrClient; $vcr = new VcrClient( inner: new GuzzleHttp\Client(), cassette: 'shopify/get-product', );
What lands on disk
A cassette is a plain JSON file, meant to be read in review and edited by hand when needed:
{
"schemaVersion": 1,
"interactions": [
{
"request": {
"method": "GET",
"uri": "https://api.example.com/greeting",
"headers": {},
"body": ""
},
"response": {
"status": 200,
"headers": { "Content-Type": ["application/json"] },
"body": "{\"hello\":\"world\"}"
},
"outcome": "success",
"recordedAt": "2026-08-21T10:00:00+00:00"
}
]
}
Cassettes live in tests/Cassettes/ by default, with the cassette name as a path inside
it. Bodies that are binary or oversized go to sidecar files; compressed responses are
decoded before storage. YAML is available as an opt-in serializer, and HAR import/export
moves traffic to and from a browser's Network tab, Postman or a proxy.
Record modes
Three cases decide what happens when an incoming request matches nothing in the cassette:
RecordMode |
Behavior |
|---|---|
RecordIfAbsent (default) |
No cassette → record everything. Cassette exists → replay only; an unmatched request throws. |
ExtendCassette |
Replays what exists and appends unmatched requests as new recordings, leaving the rest untouched. |
PlaybackOnly |
Never records. A missing cassette or a changed request shape fails loudly. |
The declared mode never changes based on the environment. Protecting CI is a separate
switch, VCR_ALLOW_RECORDING, which sits above RecordMode and blocks the recording
branch of whichever mode is declared:
VCR_ALLOW_RECORDING |
Result |
|---|---|
1 or 0 |
exactly that — an explicit value always wins |
| unset, CI detected | recording blocked |
| unset, no CI signal | recording allowed |
CI detection is narrow and fully enumerated: a non-empty CI, CONTINUOUS_INTEGRATION,
BUILD_NUMBER, JENKINS_URL or TEAMCITY_VERSION. Setting VCR_ALLOW_RECORDING=0 in the
pipeline is one line and recommended regardless.
Re-recording
VCR_ERASE_TAPE names what to erase and record fresh. It takes a target, never a bare
1 — so the shortest thing to type is not also the one with the widest blast radius:
# one cassette VCR_ERASE_TAPE=shopify/get-product vendor/bin/phpunit # every Shopify interaction, in every cassette the run opens; everything else replays SHOPIFY_API_KEY=xxx VCR_ERASE_TAPE=@shopify vendor/bin/phpunit # only the Shopify interactions inside one cassette SHOPIFY_API_KEY=xxx VCR_ERASE_TAPE=sync/order-flow@shopify vendor/bin/phpunit # everything the run opens, said out loud VCR_ERASE_TAPE=all vendor/bin/phpunit
A cassette recorded from a test that talks to two APIs is refreshable one API at a time: interactions belonging to other providers survive the truncation and keep replaying, so the run needs credentials only for the API being refreshed.
Matching
Which recorded interaction a request corresponds to is decided by a composable list of
matchers, all of which must agree. The default set is
[MethodMatcher, UriMatcher, QueryStringMatcher].
new VcrClient($inner, cassette: 'shopify/get-product', matchers: [ new MethodMatcher(), new UriMatcher(), new QueryStringMatcher(), new HeadersMatcher(['X-Shop-Domain']), ]);
| Matcher | Compares |
|---|---|
MethodMatcher |
HTTP method, case-insensitively |
UriMatcher |
scheme + host + path, normalized |
HostMatcher |
host only, when matching the full path is too strict |
QueryStringMatcher |
query params as an unordered set; repeated keys keep their order |
HeadersMatcher |
named headers, subset match by default, exact: true for both directions |
BodyMatcher |
raw body, exact |
BodyJsonMatcher |
semantic JSON — key order doesn't matter, types are compared strictly |
Values that legitimately change every run are handled on the JSON matcher, since redaction can't help with a value not known in advance:
(new BodyJsonMatcher()) ->ignoreJsonField('/transactionId') // any value counts as equal ->matchJsonField('/requestId', '/^[0-9a-f-]{36}$/'); // must look like a UUID
Writing your own means implementing one method — matchers compare two RecordedRequest
snapshots, not live PSR-7 objects, so no matcher can drain a stream out from under the next
one:
interface RequestMatcherInterface { public function matches(RecordedRequest $recorded, RecordedRequest $incoming): bool; }
Redaction and secret scanning
Authorization, Proxy-Authorization, Cookie and Set-Cookie are redacted from the
first recording with nothing to configure. Anything else is opt-in:
$vcr->redact('<SHOPIFY_API_KEY>', fn () => $_ENV['SHOPIFY_API_KEY']); $vcr->redactHeader('X-Api-Key'); $vcr->redactJsonField('/customer/email'); $vcr->redactQueryParam('api_key'); // ?api_key=xxx in the URL itself $vcr->redactFormField('client_secret'); // form-encoded body
Redaction is symmetric: the real value is replaced with the placeholder before anything touches disk, and swapped back at replay time — on the recorded request before matching, and on the recorded response before application code receives it. It covers everything stored in an interaction, including the message of a recorded transport failure.
Both are built on a general hook pipeline (beforeRecord / beforePlayback) that's open
for anything else an interaction needs done to it on the way in or out.
On top of that, every session that records anything runs the new interactions through a credential heuristic and warns:
http-vcr: recorded 1 interaction → tests/Cassettes/shopify/get-product.json
response.body carries a credential-shaped value, stored unredacted:
"sk_live_4eC39H…"
It never fails a test and never blocks the write — the point is to put the finding in front
of you while the context is fresh, before the file is committed. For the blocking version
across every cassette, with an exit code CI can act on, run vendor/bin/http-vcr scan-secrets.
More control
- Strict mode —
StrictMode::AllPlayedfails when the cassette closes with an interaction nothing ever asked for;StrictMode::InOrderrequires the recorded sequence to be replayed in order. staleAfter— flags interactions older than aDateInterval. Informational by default (vendor/bin/http-vcr staleas a non-blocking CI step), enforced per-run withVCR_ENFORCE_STALE_CHECK=1. "Now" comes from an injectable PSR-20 clock, andFrozenClockships with the package so testing this needs no extra dependency.- Locked interactions — the write-protect tab.
vendor/bin/http-vcr lock shopify/checkout --interaction=2means that interaction never generates a real request again, aboveVCR_ERASE_TAPEandVCR_ALLOW_RECORDINGboth. For a request that charges a card or creates an order. - Scoping by URL —
RegexUrlScopeResolver('#/api/(?<scope>\d{4}-\d{2})/#')stores an API version in its own file (get-product.2024-01.json), so a version bump gives a clear "nothing recorded for this version" rather than a silent match against outdated data. - Transport errors — opt in with
recordTransportErrors: trueto record a timeout or connection failure as a deterministic interaction, for testing retry logic. Replay throwsVcrNetworkException/VcrRequestException, which implement the PSR-18 interfaces. - Repeatable playback — an interaction that isn't consumed when replayed, for the target of a retry loop.
Framework integration
| How | |
|---|---|
| Any PSR-18 client | new VcrClient($client, cassette: '…') — nothing else needed |
| Guzzle | VcrMiddleware on the HandlerStack, so $client->get() and friends are covered too — those bypass any decorator around the client |
| Symfony | VcrHttpClient implements HttpClientInterface, the one a Symfony app injects into services. Psr18Client needs no bridge |
| PHPUnit | #[UseCassette], #[CassetteDirectory], InteractsWithCassettes, Extension. PHPUnit 10–13 |
| Laravel | mtk3d/laravel-http-vcr — a separate package: auto-registered provider, Http facade interception, artisan vcr:*. Laravel 11+ |
Runnable versions of each are in examples/.
CLI
vendor/bin/http-vcr <command> [--config=path/to/http-vcr.php]
| Command | Does |
|---|---|
stale |
Lists interactions past their staleAfter |
tests |
Lists tests touching a provider, plus a ready-made --filter regex |
providers |
Every configured provider with its hosts, requiresEnv and cassette counts, then the hosts running unclaimed |
scan-secrets |
Credential scan across every cassette, with an exit code |
lock / unlock |
Sets or clears the write-protect flag on an interaction |
None of these run the test suite: #[UseCassette] is read by parsing the source, so no
command needs a correctly configured environment to answer a question about cassettes. That
also means anything not statically resolvable (staleAfter: self::INTERVAL, a computed
cassette name) is reported as "couldn't be fully analyzed" rather than guessed at.
The tests command exists to make a targeted re-record quick:
SHOPIFY_API_KEY=xxx VCR_ERASE_TAPE=@shopify \
vendor/bin/phpunit --filter "$(vendor/bin/http-vcr tests --provider=shopify --filter-only)"
The filter is only ever a speed optimization — what gets erased is decided by
VCR_ERASE_TAPE, so the same command without it produces the same cassettes, just slower.
Configuration
Everything is optional; http-vcr works with none of it. Project-wide defaults go in an
http-vcr.php found by walking up from the working directory, no further than the
directory holding composer.json:
<?php use HttpVcr\Config; use HttpVcr\Provider; return Config::create( cassetteDirectory: __DIR__ . '/tests/Cassettes', providers: [ 'shopify' => new Provider(hosts: ['*.myshopify.com'], requiresEnv: ['SHOPIFY_API_KEY']), ], redact: ['<COMPANY_PROXY_TOKEN>' => fn () => $_ENV['COMPANY_PROXY_TOKEN']], innerClientFactory: fn () => new GuzzleHttp\Client(['timeout' => 30]), );
A provider is a name for an external API: host patterns plus the environment variables
recording it requires. Naming one buys VCR_ERASE_TAPE=@shopify and a check that fails
before anything is recorded against a missing credential — VCR_ERASE_TAPE=@api.stripe.com
works against a bare hostname without any configuration either way.
The same object can be filled in from code instead, for projects that would rather configure in a PHPUnit bootstrap than add a file:
VcrClient::configure(cassetteDirectory: __DIR__ . '/Cassettes');
Documentation
mtk3d.github.io/http-vcr — the full book, covering every option in reference tables, plus the parts this README only names: the cassette format, the hook pipeline, storage backends and serializers, the environment-variable precedence table, and every exception with the question it answers.
The source is in docs/; mdbook serve docs reads it locally.
PLAN.md carries the design decisions, each with the alternatives that were
rejected and why. It's written in Polish; everything else is in English.
Dependencies
The record/replay core depends on psr/http-message, psr/http-client,
psr/http-factory and psr/clock, and nothing else — no Guzzle, no Symfony, no framework.
The package additionally requires symfony/console and nikic/php-parser for the CLI;
since http-vcr is installed as a dev dependency, neither reaches an application's
production autoloader.
Optional, install only if needed: guzzlehttp/guzzle (the middleware bridge),
symfony/http-client (the native HttpClientInterface bridge), symfony/yaml (the YAML
serializer), phpunit/phpunit (the attribute and trait).
Development
composer install vendor/bin/phpunit vendor/bin/phpstan analyse # level max vendor/bin/pint --test # Laravel Pint, laravel preset
All three run in CI across PHP 8.2–8.5. Conventions for working on this repository are in AGENTS.md.
License
MIT — see LICENSE.