http-vcr — record and replay HTTP requests in PHP tests
http-vcr records real HTTP interactions the first time your tests run, and replays them on every run after that — so the test suite for “the code that talks to Shopify/Stripe/Zendesk” is fast, deterministic, and doesn’t need network access or API credentials in CI.
It works by decorating a PSR-18 HTTP client (Psr\Http\Client\ClientInterface). Anything that already speaks PSR-18 — Guzzle 7+, Symfony’s Psr18Client, php-http’s clients — works with zero configuration. Recording and replay happen inside the VcrClient instance you construct, so there is no process-wide state to install or reset between tests.
$vcr = new VcrClient($realClient, cassette: 'shopify/get-product');
$response = $vcr->sendRequest($request);
// first run: real request happens, response is recorded to tests/Cassettes/shopify/get-product.json
// every run after: no network call, the recorded response is replayed
That first run records without any extra setup on a developer machine, and refuses to record on CI — see Record Modes for how that’s decided and how to override it in either direction.
Where to go next
- Installation and your first cassette
- How It Works for the architecture
- Record Modes for what happens during recording vs. playback
- VcrClient Reference for every option in one table
Installation
composer require --dev mtk3d/http-vcr
You need two things:
- PHP 8.2 or newer.
- An HTTP client to wrap — whichever one your project already uses. Guzzle 7+, Symfony’s
Psr18Client, php-http, Buzz: all of them work as they are, because all of them implementPsr\Http\Client\ClientInterface, which is the one thing http-vcr wraps.
That’s the whole list. To hand a recorded response back to your code, http-vcr also needs a factory that builds response objects — but it takes that from the HTTP client library you already have (Guzzle ships one, Symfony’s client pulls one in, and so on), so there is nothing extra to install, register, or pass in. In the unlikely case that nothing usable is found, http-vcr stops before the first request rather than partway through one, and composer require --dev nyholm/psr7 settles it. If you’d rather supply your own, see the VcrClient reference.
The record/replay core depends only on the PSR interfaces themselves (psr/http-message, psr/http-client, psr/http-factory, psr/clock) — no Guzzle, no Symfony, no framework. The package also pulls in symfony/console and nikic/php-parser, used exclusively by the CLI; since http-vcr is a dev dependency, those never reach a production autoloader.
Optional pieces
Install these only if needed:
| Package | Needed for |
|---|---|
guzzlehttp/guzzle | The VcrMiddleware bridge for a Guzzle HandlerStack — plain GuzzleHttp\Client works without it |
symfony/http-client | The VcrHttpClient bridge for Symfony’s native HttpClientInterface — Psr18Client works without it |
symfony/yaml | The YAML cassette serializer, for teams that would rather not use the default JSON one |
phpunit/phpunit | The #[UseCassette] attribute and InteractsWithCassettes trait. The attribute is built on the Extension API, so the bridge supports PHPUnit 10 through 13 — http-vcr’s own test suite runs on 11.5–13, but that’s a separate matter from what the bridge supports in your project |
mtk3d/laravel-http-vcr | Zero-setup use in a Laravel app — auto-registered service provider, Http facade interception, artisan vcr:* commands. A separate package that depends on this one; needs Laravel 11 or newer |
See Framework Integration for details on each.
Using it with PHPUnit
One line in phpunit.xml registers the extension that powers #[UseCassette]:
<extensions>
<bootstrap class="HttpVcr\Bridge\PHPUnit\Extension"/>
</extensions>
PHPUnit doesn’t discover extensions on its own, so without this the attribute has no effect at all — see PHPUnit Integration.
The CLI
Composer links the CLI into the consuming project’s vendor/bin:
vendor/bin/http-vcr providers
In a Laravel app the same commands are also available through artisan vcr:* — see the CLI Reference.
Where cassettes go
By default, tests/Cassettes/ relative to the project root (the directory containing composer.json), with the cassette name as a path inside it — shopify/get-product becomes tests/Cassettes/shopify/get-product.json. Change it with cassetteDirectory in http-vcr.php.
Cassettes are meant to be committed. The lock files http-vcr uses while recording are not, and take no setup either way: they go in a .http-vcr/ directory inside the cassette directory, which ignores itself.
Quick Start
Say a class fetches a product from Shopify through a PSR-18 client:
final class ShopifyClient
{
public function __construct(
private ClientInterface $http,
private RequestFactoryInterface $requestFactory,
) {}
public function getProduct(string $id): array
{
$request = $this->requestFactory->createRequest(
'GET',
"https://shop.myshopify.com/admin/api/2024-01/products/{$id}.json",
);
$response = $this->http->sendRequest($request);
return json_decode((string) $response->getBody(), true);
}
}
One-time setup
Register the PHPUnit extension in phpunit.xml. PHPUnit has no auto-discovery for extensions, so this is the one line http-vcr can’t add for you:
<extensions>
<bootstrap class="HttpVcr\Bridge\PHPUnit\Extension"/>
</extensions>
That’s the whole setup. No config file, no bootstrap code, no cassette directory to create.
Write the test
Put #[UseCassette] on the test and take the client from the trait — that’s it:
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 and no test-only branch in the code under test. The factory in the example is ShopifyClient’s own, for building requests; http-vcr never asks you for one.
First run: record
On a developer machine, recording is allowed by default. With a real API key available, the call goes over the network and is written to tests/Cassettes/shopify/get-product.json:
SHOPIFY_API_KEY=sk_live_xxx vendor/bin/phpunit --filter testGetProduct
Every run after: replay
The cassette exists now, so the default RecordIfAbsent mode replays it instead of recording again. The same test, with no network call, no API key, and no flakiness:
vendor/bin/phpunit --filter testGetProduct
Commit the cassette alongside the test. Any credentials that were in the Authorization or Cookie headers were replaced with placeholders before it hit disk.
On CI: never record
CI is detected from the environment (CI, CONTINUOUS_INTEGRATION, JENKINS_URL and a couple more — the full list is in the Environment Variables reference), and recording is refused there: a missing cassette fails the test loudly instead of quietly reaching for the real API without credentials.
That’s a default, not a hard rule. An explicitly set VCR_ALLOW_RECORDING=0 or =1 always wins — set 0 in the pipeline if you’d rather not rely on detection at all, or 1 locally if something in your shell happens to set CI.
Without PHPUnit
The attribute is a convenience over an ordinary object. Anywhere else — a script, a different test framework — construct it directly:
use HttpVcr\VcrClient;
$vcr = new VcrClient(
inner: new GuzzleHttp\Client(),
cassette: 'shopify/get-product',
);
$shopify = new ShopifyClient($vcr, new GuzzleHttp\Psr7\HttpFactory());
Everything the attribute sets — record mode, matchers, redaction, strict mode — is a constructor argument or a method on this object. See the VcrClient Reference.
Where to go next
- PHPUnit Integration — the full attribute API, and refreshing one external API’s recordings without touching the rest
- Guzzle — why code calling
$client->get()needs the middleware rather than the decorator - Laravel — the
Httpfacade, intercepted with no wiring in the test
How It Works
http-vcr sits between application code and a real PSR-18 HTTP client, as a plain decorator:
Your code
│ Psr\Http\Client\ClientInterface
▼
HttpVcr\VcrClient
│
├── decides: replay from cassette, or make a real request?
├── Matching\RequestMatcherInterface[] — which recorded interaction (if any) matches?
├── Hook\HookRegistry — beforeRecord / beforePlayback callables, transforming an
│ interaction before it's written / before it's replayed
│ (redaction is built on this — see Hooks)
├── PSR-17 factories — rebuild a live response object from the stored snapshot
└── Persistence\CassettePersisterInterface — where cassettes live (filesystem by default)
└── Serializer\CassetteSerializerInterface — the on-disk format (JSON by default)
│
▼ (only when actually recording)
Real Psr\Http\Client\ClientInterface (Guzzle, Symfony, ...)
Because VcrClient itself implements Psr\Http\Client\ClientInterface, application code never knows it’s talking to a decorator instead of the real client — sendRequest() has the exact same signature either way.
What actually gets stored
A cassette is a JSON file containing a list of interactions — each one a recorded request and its response, plus a little metadata (when it was recorded, whether it’s locked, and so on). See The Cassette Format for the full shape.
Interactions hold plain values, never live PSR-7 objects: method and URI as strings, headers as arrays, body as a string. That’s not an implementation detail — PSR-7 bodies are StreamInterface, which is mutable, so one matcher reading a body could leave the next one with an empty stream. An incoming request is converted to that same value shape (RecordedRequest) once, at the edge of sendRequest(), and everything downstream — matchers, hooks, redaction — works on it. A live ResponseInterface is built back up, through PSR-17 factories, only at the very last moment before it’s returned.
What one cassette covers
A cassette is the recording of one test’s HTTP traffic — not one API’s. Nothing in the format ties a file to a single service: an interaction holds a request and what came back, and which external API it belongs to is worked out from the request’s host whenever something needs to know, never stored. A test that pulls an order out of Shopify and opens a Zendesk ticket from it records both halves into one file and replays both from it.
That’s what decides how to name one. A cassette name is a path inside the cassette directory (shopify/get-product → tests/Cassettes/shopify/get-product.json) and http-vcr reads nothing into it — the leading segment is free to be a service, a module, or absent. So name it after what the test does: shopify/get-product when the test really is one call to one API, sync/order-flow when it walks through several.
Two things follow, and both bite when one file is shared between tests:
StrictMode::AllPlayedandInOrderare assertions about a single file. A cassette two tests write into fails them for reasons belonging to the other test.ExtendCassetteappends whatever didn’t match, so a shared file grows into the union of every test that ever touched it.
One file per test avoids both. When such a file spans several APIs, VCR_ERASE_TAPE=@shopify re-records just that API’s interactions inside it and leaves the rest replaying — see VCR_ERASE_TAPE selectors.
What decides “replay or record”
Two things, together:
- The record mode —
RecordIfAbsent,ExtendCassette, orPlaybackOnly— plus two separate, env-only switches: whether recording is permitted at all, and forced re-recording. See Record Modes and the Environment Variables reference. - Whether an incoming request matches an interaction already in the cassette, decided by a composable set of matchers (method, URI, headers, body, …). See Matching Requests.
If a request matches an existing, unconsumed interaction, it’s replayed — no network call, ever. If it doesn’t match anything, what happens next depends entirely on the record mode; see Exceptions for which failure you get when it isn’t allowed to record.
On either path, an interaction passes through the hook chain — beforeRecord on the way to disk, beforePlayback on the way out — which is also where redaction happens.
No global state
Nothing here touches anything outside the VcrClient instance that was constructed. Two tests running in the same process with two different VcrClient instances — different cassettes, different inner clients — never interfere with each other. There’s no shared, process-wide state to reset between tests.
Project-wide configuration is the one thing that is process-wide, and it’s frozen before the first VcrClient exists precisely so this claim stays true rather than depending on test execution order — see Configuration Reference.
Record Modes
Every VcrClient is opened in one of three RecordMode cases, controlling what happens when an incoming request doesn’t match anything already in the cassette. There’s also a fourth behavior, forced recording, that deliberately isn’t one of these three — see the last section on this page.
RecordIfAbsent (default)
- Cassette doesn’t exist yet → record everything for real, write a new cassette.
- Cassette already exists → replay only. An unmatched request throws
NoMatchingInteractionExceptioninstead of silently hitting the real API.
This is the mode for a new test: run it once against the real API to create the cassette, then every run after is pure replay.
new VcrClient($inner, cassette: 'shopify/get-product', mode: RecordMode::RecordIfAbsent);
ExtendCassette
Replays existing interactions, and appends any unmatched request as a new recording, without touching what’s already there. Useful when a test’s code path grows over time — a new call added to the method under test — and re-recording everything that already works isn’t necessary.
PlaybackOnly
Never records, ever, even if the cassette doesn’t exist or nothing matches — any miss throws: a missing cassette or a changed request shape fails the test loudly instead of silently making a real network call.
This is a RecordMode you declare explicitly, like the other two. http-vcr never swaps the declared mode based on the environment — what a test declares is what it runs with, everywhere. Protecting CI is the job of a separate switch, described next.
When recording is allowed at all
VCR_ALLOW_RECORDING sits above RecordMode rather than being one of its cases: set to 0, it blocks the recording branch of whatever mode is declared — including RecordIfAbsent’s “cassette doesn’t exist yet → record it” — without changing the declared mode. The result is the same visible behavior as PlaybackOnly (an unmatched request throws), reached without editing a single test.
It has three states, not two:
VCR_ALLOW_RECORDING | Result |
|---|---|
set to 1 or 0 | exactly that — an explicit value always wins |
| unset, CI detected | recording blocked |
| unset, no CI signal | recording allowed |
CI detection is deliberately narrow and fully enumerated, so it can be predicted without reading the source: any non-empty (and not 0/false) value of CI, CONTINUOUS_INTEGRATION, BUILD_NUMBER, JENKINS_URL, or TEAMCITY_VERSION. The first two cover GitHub Actions, GitLab CI, CircleCI, Travis, Buildkite, Drone and most hosted runners; the rest cover Jenkins and TeamCity, which don’t set them.
Both ways this can be wrong are survivable, which is the only reason a heuristic is acceptable here at all:
- False positive (a local machine that sets
CIfor unrelated reasons) → recording blocked, which is the safe direction. The error names the variable that triggered detection, so it’s traceable rather than spooky action at a distance. - False negative (an exotic runner not on the list) → recording allowed, which is the same as the local default. Setting
VCR_ALLOW_RECORDING=0in the pipeline config is one line and is recommended regardless of detection.
The full precedence rules, including how this interacts with VCR_ERASE_TAPE, are in the Environment Variables reference.
Forced recording — not a RecordMode case
VCR_ERASE_TAPE=shopify/get-product vendor/bin/phpunit
VCR_ERASE_TAPE takes a cassette name — not a bare 1/0. Whichever cassette it names is truncated on open — down to nothing, or down to whatever the selector spares (locked interactions, and other providers’ traffic if the selector names one) — and every request that doesn’t match what survived is executed for real and recorded fresh, regardless of whatever RecordMode was declared in code. Everything else in the same test run is untouched, even if the whole suite happens to run in the same process. Use it to deliberately re-record a cassette from scratch, for example after an upstream API changed its response shape.
A comma-separated list targets a few specific cassettes, and VCR_ERASE_TAPE=all erases every cassette the run opens. The other half of the selector syntax narrows in the perpendicular direction — by external API rather than by file:
# 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
That’s what makes a cassette recorded from a test talking to two APIs 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. Which interaction belongs to which provider follows from the request host.
None of these examples set VCR_ALLOW_RECORDING=1, because locally it’s already the default — VCR_ERASE_TAPE needs recording to be permitted, and on a developer machine it is. Add it explicitly if something in your shell sets CI; on CI itself, VCR_ALLOW_RECORDING=0 wins over VCR_ERASE_TAPE by design.
A bare VCR_ERASE_TAPE=1 is rejected with an error rather than treated as “erase everything”. A boolean would make the shortest thing to type also the one with the widest blast radius: every cassette the run happened to open, which is the whole suite unless you remembered a test filter. Naming the target instead means the filter is only ever a speed optimization — skip the tests that can’t change anything — and never the thing standing between you and an accidental mass re-record. If you do want everything, all says so out loud.
There’s no RecordMode case for this, and no way to add one: forced recording exists only as an environment variable, so “always hit the real API” can’t be hardcoded into a test and committed. It’s always a deliberate, one-off decision about a specific run.
Forced recording respects locked interactions: anything marked locked in the cassette is excluded from the truncation and keeps being replayed from the existing recording — the mutating request that can’t safely be repeated stays frozen while everything else refreshes. Everything that survives truncation — locked interactions, plus other providers’ traffic when the selector named one — keeps its relative order and stays at the front of the file; freshly recorded interactions are appended after them, which is worth knowing if the cassette is also under StrictMode::InOrder.
A cassette locked in its entirety (#[UseCassette(locked: true)]) plus VCR_ERASE_TAPE erases nothing and records nothing — the file comes out of the run byte-for-byte identical. That’s the lock working as promised, not an error, but the run reports it (cassette fully locked, VCR_ERASE_TAPE had no effect) so it doesn’t look like a silently ignored variable.
This is different from ExtendCassette, which never removes or replaces what’s already recorded — it only adds. Forced recording starts over.
What happens to an interaction once it’s replayed
By default, each interaction can be replayed exactly once per cassette session — once it’s matched a request, it’s “consumed” and won’t be offered again for a later request, even an identical one. Set repeatablePlayback: true (per cassette or per interaction) when the code under test is expected to make the same call more than once, for example retry logic.
The Cassette Format
A cassette is a JSON file — human-readable on purpose, so a change to it shows up as an honest diff in a pull request, and so it can be hand-edited when needed (see Locked Interactions for one example of why that matters).
{
"schemaVersion": 1,
"interactions": [
{
"request": {
"method": "GET",
"uri": "https://shop.myshopify.com/admin/api/2024-01/products/123.json",
"headers": {
"accept": ["application/json"],
"authorization": ["<REDACTED-AUTHORIZATION>"]
},
"body": ""
},
"response": {
"status": 200,
"headers": {
"content-type": ["application/json"]
},
"body": "{\"id\":123,\"title\":\"T-Shirt\"}"
},
"outcome": "success",
"recordedAt": "2026-08-01T10:15:00Z",
"locked": false
}
]
}
Field reference (short version)
| Field | Meaning |
|---|---|
schemaVersion | Format version of the file. Lets http-vcr detect and migrate old cassettes instead of silently misreading them. |
outcome | "success" for a normal response, "error" for a recorded transport failure — see Transport Errors |
recordedAt | Timestamp used by staleAfter to flag old recordings |
locked | When true, this interaction can never be re-recorded — see Locked Interactions |
The full field-by-field reference, including large-body sidecar files and binary encoding, lives in the Cassette Format Reference.
Large or binary bodies
Bodies over 1 MiB (configurable) aren’t inlined as base64 in the main file — they’re written to a separate sidecar file next to the cassette, named after a hash of their content ({cassette}.{hash}.bin), and referenced from the interaction instead of embedded inline. This keeps the main JSON file readable and diffable even when one response happens to be a large download, and content-hash naming means reordering interactions by hand never breaks a sidecar reference.
Sidecars are written as raw bytes, so bodyEncoding and bodyRef never appear together — base64 exists only to fit arbitrary bytes into JSON, which a separate file doesn’t need. Sidecars nobody references any more (after a re-record, or after deleting an interaction by hand) are cleaned up when the cassette is next written. Full details in the Cassette Format Reference.
Other formats
JSON is the default, not the only option: YamlCassetteSerializer stores the same model in YAML, and HAR is supported as an import/export format for exchanging captures with browser DevTools, Postman, or Charles Proxy — but deliberately not as a storage format. See Storage & Formats.
It’s meant to be hand-edited
Redacting a field that wasn’t configured up front, deleting one interaction out of several, locking a sensitive one — all of that is a normal text edit, not something that needs a special tool. vendor/bin/http-vcr lock/unlock exist for convenience, but they’re not the only way in.
Matching Requests
When application code makes a request, http-vcr has to decide which recorded interaction, if any, it corresponds to. That decision is made by a composable list of matchers — by default method, URI and query string, extendable with more.
// the default set, plus one more — passing `matchers:` replaces the default
// list outright rather than adding to it
new VcrClient($inner, cassette: 'shopify/get-product', matchers: [
new MethodMatcher(),
new UriMatcher(),
new QueryStringMatcher(),
new HeadersMatcher(['X-Shop-Domain']),
]);
All configured matchers must agree (logical AND) for an interaction to count as a match.
The default set
[MethodMatcher, UriMatcher, QueryStringMatcher]. The query string is part of the default deliberately: UriMatcher compares scheme, host and path only, so without it ?page=1 and ?page=2 would be the same interaction — and that failure is silent. The test wouldn’t error; the code under test would just receive page two where it asked for page one, and find out several assertions later, if at all. A default should fail loudly rather than guess.
The cost lands on throwaway parameters that change every run — a cache-buster ?_=1712345678, a ?nonce=…. Those now produce a missing match, which is noisy but obvious and has an immediate fix: drop QueryStringMatcher from the matchers: list, or supply your own. That trade is on purpose — a false miss announces itself, a silent match against the wrong interaction doesn’t.
Matchers compare two RecordedRequest snapshots — the recorded one and the incoming one — not live PSR-7 objects:
interface RequestMatcherInterface {
public function matches(RecordedRequest $recorded, RecordedRequest $incoming): bool;
}
Two reasons that matters if you write your own: a snapshot’s body is a plain string, so one matcher can’t drain the stream out from under the next one, and a body large enough to have been written to a sidecar file isn’t read off disk for matchers that never look at it.
Built-in matchers
-
MethodMatcher— HTTP method, compared case-insensitively. -
UriMatcher— scheme + host + path, normalized (lowercase host, default ports stripped). The query string is handled separately. -
HostMatcher— just the host, for cases where matching the full path is too strict. -
QueryStringMatcher— query params as an unordered set (?a=1&b=2equals?b=2&a=1), but repeated keys keep their order (?tag=a&tag=bis treated as a list). -
HeadersMatcher— subset match by default: recorded headers must be present in the incoming request, but extra headers on the incoming side don’t fail the match. This matters because different HTTP client libraries add their own headers (User-Agent,Accept-Encoding) that have nothing to do with application code. Header names are lowercased before comparison, since PSR-7 treats them as case-insensitive but a recorded cassette and a live client don’t necessarily agree on capitalization. It’s the only built-in matcher that takes constructor arguments:public function __construct(array $headers = [], bool $exact = false) {}$headerslists the header names to check; empty means every header on the recorded request.exact: trueadditionally requires both sides to carry the same set of them, so a header the incoming request added is a mismatch rather than an extra — narrowed to$headerswhen one is given, and the whole header set when it isn’t. -
BodyMatcher— raw body, exact match. -
BodyJsonMatcher— semantic JSON match:{"a":1,"b":2}matches{"b":2,"a":1}. Scalar types are compared strictly and array order is significant. Falls back to a raw comparison when either body isn’t valid JSON.Two builder methods handle values that legitimately change every run (UUIDs, generated timestamps) — redaction can’t help there, since it replaces a value known in advance rather than matching one that isn’t:
(new BodyJsonMatcher()) ->ignoreJsonField('/transactionId') // any value on either side counts as equal ->matchJsonField('/requestId', '/^[0-9a-f-]{36}$/'); // must *look* like a UUID, need not be identicalBoth return a new matcher rather than mutating the receiver, so a matcher stays a value that can be built in one expression inside the
matchers:array.
Redacted values are normalized on both sides
A value redacted two-way (redact() with a value provider, or a helper given one) is restored to its real value on the recorded side before the comparison, and matched normally.
A value redacted one-way — the four auto-redacted headers, or any redactHeader()/redactJsonField()/redactQueryParam()/redactFormField() call without a value provider — is stored as a placeholder http-vcr has no way to turn back into the original. Comparing <REDACTED-AUTHORIZATION> against a real token would never match, so http-vcr goes the other direction: it applies the same record-direction redaction to the incoming request before matching, leaving the same placeholder on both sides. Otherwise turning on redaction would break replay, which is exactly the failure mode http-vcr exists to avoid.
The visible effect is that a one-way redacted field stops distinguishing interactions — two recordings that differ only in their Authorization header become indistinguishable to the matchers. The reason for doing it by normalizing rather than by teaching matchers to skip fields: it needs no extra matcher API, and it works for BodyMatcher too, where “skip the client_secret field” has no meaning on a raw string. Only the redaction transform is applied to the incoming request — not the rest of the beforeRecord chain, which may have side effects and is meant to run once per recorded interaction.
If you specifically need to match on an auto-redacted header, opt it out of redaction with includeSensitiveHeaders(['Authorization']) — one deliberate decision rather than two independent settings that have to agree.
When nothing matches
Whenever an unmatched request isn’t allowed to fall through to a real recording — in PlaybackOnly mode, or in RecordIfAbsent once the cassette already exists — it throws NoMatchingInteractionException, and the message is built to actually help with debugging it:
No matching interaction for GET https://shop.myshopify.com/admin/api/2024-01/products/123.json
Cassette tests/Cassettes/shopify/get-product.json, 2 unconsumed interactions:
#1 BodyJsonMatcher: field "status" expected "active", got "pending"
#2 UriMatcher: expected path "/admin/api/2024-01/products/124.json"
It shows the first matcher that rejected each unconsumed interaction, with a short expected-vs-actual comparison for that matcher — not a wall of every matcher’s opinion on every interaction, and not just “nothing matched” with no further clue. Interactions rejected on MethodMatcher/UriMatcher stop there rather than reporting matchers that never got to see the rest of the request.
When it’s VCR_ALLOW_RECORDING=0 that’s standing in the way of a recording — the run would have recorded, and would have succeeded with recording allowed — the exception is RecordingNotAllowedException instead, naming that variable as the actual cause. And when there’s no cassette file at all, it’s CassetteNotFoundException. See Exceptions for which one you get when.
Hooks
Everything http-vcr does to an interaction on its way to disk, or on its way back out, goes through one mechanism: a list of callables that take an Interaction and return a new one. Redaction isn’t a special case in the core — it’s a pair of these hooks with a convenience API on top.
$vcr->beforeRecord(fn (Interaction $i) => /* ... */);
$vcr->beforePlayback(fn (Interaction $i) => /* ... */);
Interaction is a readonly class, and so are the RecordedRequest/RecordedResponse it holds. A hook never mutates what it was handed — it returns a new instance (or the same one unchanged, when there’s nothing to do). That’s what makes the ordering guarantees below meaningful: no hook can quietly change what an earlier one already looked at.
What a hook is handed
final readonly class Interaction {
public RecordedRequest $request;
public ?RecordedResponse $response; // null only when $outcome is Outcome::Error
public Outcome $outcome; // Outcome::Success | Outcome::Error
public ?RecordedError $error; // category / message / original class, null on success
public DateTimeImmutable $recordedAt;
public bool $locked;
public bool $repeatablePlayback;
public function withRequest(RecordedRequest $request): self;
public function withResponse(RecordedResponse $response): self;
public function withError(RecordedError $error): self;
}
final readonly class RecordedRequest { // RecordedResponse is the same, with $status instead of $method/$uri
public string $method;
public string $uri;
/** @var array<string, string[]> */
public array $headers;
public string $body;
public ?string $bodyEncoding; // 'base64', or null for text
public function withUri(string $uri): self;
public function withHeaders(array $headers): self;
public function withHeader(string $name, string|array $value): self;
public function withoutHeader(string $name): self;
public function withBody(string $body, ?string $encoding = null): self;
}
Every with*() returns a new instance, so a hook is a chain of expressions rather than a sequence of assignments:
// drop a huge response body the test doesn't care about, so it never reaches disk
$vcr->beforeRecord(fn (Interaction $i) => $i->withResponse($i->response->withBody('')));
// strip a volatile header
$vcr->beforeRecord(fn (Interaction $i) => $i->withResponse($i->response->withoutHeader('X-Request-Id')));
bodyRef and bodySha256 are deliberately not part of this surface. Splitting a large body out to a sidecar file happens during serialization, after every hook has run, so a hook always sees the full content in $body and never has to wonder whether it got a reference instead.
beforeRecord
Runs on the way to disk, after a real request has completed and before anything is serialized.
// don't persist transient upstream failures into a regression test
$vcr->beforeRecord(fn (Interaction $i) => $i->response?->status >= 500 ? null : $i);
Its type is callable(Interaction): ?Interaction. null is a legal return value and means “don’t record this interaction.” That isn’t an error: the request was really made, its response goes back to the code under test as usual, and only the cassette write is skipped. The first hook to return null ends the chain — the ones after it have nothing left to receive.
Other things this is the right place for: stripping a volatile response header, replacing an enormous body the test doesn’t care about with an empty one so it never reaches disk at all, normalizing a timestamp the API echoes back.
beforePlayback
Runs on the way out of the cassette, and — this is the part worth internalizing — before the matchers compare anything. A recorded request transformed here is the one matching sees.
Its type is callable(Interaction): Interaction. null is not allowed: the interaction already exists and has already been matched, and “don’t replay it” isn’t an answer to “what should sendRequest() return?”. Returning null here is a programming error and throws LogicException rather than being silently swallowed.
This is where two-way redaction puts the real value back — both into the recorded request, so it can be compared against a live one, and into the recorded response, so application code receives a usable token rather than a placeholder.
Ordering
Within one direction, hooks run in registration order (FIFO). Rules declared in http-vcr.php are registered before anything added imperatively on an instance, so a project-wide redaction rule always runs first.
Across mechanisms, the write path has a fixed order that matters for correctness — decompression, then beforeRecord (redaction included), then the sidecar threshold check, then serialization. It’s spelled out in the Cassette Format Reference: the reason it’s fixed is that redaction has to see decompressed text, and a sidecar must never be written before redaction has run over it.
Hooks and matching
One deliberate asymmetry: before matching, http-vcr applies the record-direction redaction transform to the incoming request, so one-way redacted fields line up on both sides (see Matching Requests). It does not run the rest of the beforeRecord chain there. Your hooks may have side effects, or assume they run once per recorded interaction; redaction is a pure substitution the library owns, and is the only thing that has to hold on both sides for matching to work at all.
When to register them
Hooks are part of a VcrClient’s configuration, so they follow the same rule as everything else: register them before the first request of the cassette session, or get a LogicException. See VcrClient Reference.
Redacting Sensitive Data
Recording real API traffic means real credentials pass through http-vcr on their way to disk.
What happens without any configuration
Four headers are redacted automatically, from the first recording, with nothing to set up: Authorization, Proxy-Authorization, Cookie, Set-Cookie. They almost always carry a credential and almost never carry anything a test asserts on, so the cost of redacting them by default is close to zero — and the cost of not doing it is a token in git history, which is permanent.
"authorization": ["<REDACTED-AUTHORIZATION>"]
Anything else — a key in a query string, a secret in a form body, an email in a response you’d rather not commit — is opt-in, and the rest of this page is about it. Which is exactly why there’s also a second safety net that needs no configuration either:
The automatic check after recording
Every session that records something runs the newly recorded interactions through a credential heuristic (Bearer tokens, AWS-style keys, long token-shaped strings, auth headers that don’t look like placeholders) and warns about what it finds:
http-vcr: recorded 1 interaction → tests/Cassettes/shopify/get-product.json
response.body carries a credential-shaped value, stored unredacted:
"sk_live_4eC39H…"
It reports what it found and where it sits. What to do about it is a judgement it can’t make for you: sk_live_… in a payment test is a real leak, the same string in a fixture describing an error response is not. Redact and record again, or leave it as it is.
It never fails a test and never blocks the write — the cassette is on disk either way, and the point is to put the finding in front of you while the context is still fresh, before the file is committed. Only interactions recorded in that session are checked, so a finding you’ve looked at and accepted doesn’t come back every run.
For the blocking version — every cassette, with an exit code CI can act on — run vendor/bin/http-vcr scan-secrets. To turn the automatic check off, set scanRecordingsForSecrets: false in http-vcr.php; there’s deliberately no environment variable for it, since silencing a secrets warning should be a decision visible in review rather than something appended to one command.
$vcr->redact('<SHOPIFY_API_KEY>', fn () => $_ENV['SHOPIFY_API_KEY']);
Redaction is symmetric: it applies to both the request and the response, at write time and again, in reverse, at replay time.
How it works
Two things happen, at two different moments:
- When recording: the real value is replaced with the placeholder before anything touches disk.
- When replaying: the placeholder is swapped back for the real value — twice. Once on the recorded request, before it’s compared against the incoming request (otherwise a matcher would compare a placeholder against a real value and never match). And once on the recorded response, before it’s handed back to application code — otherwise the code would receive the placeholder string instead of the real token it expects to work with. This second half only applies to rules that were given a way to produce the real value — see One-way vs. two-way.
Redaction covers everything stored in an interaction, including the errorMessage of a recorded transport failure — HTTP client exception messages routinely quote the full request URL, query string and all.
Helpers for common cases
$vcr->redactHeader('X-Api-Key');
$vcr->redactJsonField('/customer/email');
$vcr->redactQueryParam('api_key'); // ?api_key=xxx in the URL itself
$vcr->redactFormField('client_secret'); // application/x-www-form-urlencoded body
redact() takes the placeholder as its first argument, but these four are only given a field name, so they generate one: <REDACTED-{NAME}>, with the name upper-cased and anything outside [A-Z0-9] turned into a dash. redactHeader('X-Api-Key') writes <REDACTED-X-API-KEY>, redactJsonField('/customer/email') writes <REDACTED-CUSTOMER-EMAIL>. The value is fixed rather than random, so a cassette diff stays readable and scan-secrets recognizes it as a placeholder from its shape alone.
All redaction methods, like every other configuration call on VcrClient, have to run before the first request of the cassette session — see VcrClient Reference.
One-way vs. two-way
“Symmetric” above describes two different axes, and only one of them is unconditional.
Request and response — always both. Every helper covers both halves of an interaction, wherever the field makes sense on both sides.
Record and replay — only when http-vcr knows the real value, which means only when you hand it one:
$vcr->redact('<API_KEY>', fn () => $_ENV['API_KEY']); // two-way
$vcr->redactHeader('X-Api-Key', fn () => $_ENV['API_KEY']); // two-way
$vcr->redactHeader('X-Api-Key'); // one-way: write only
A one-way rule replaces the value with a placeholder on the way to disk and has nothing to restore it from on the way back. Two consequences, both of which look like bugs if you don’t expect them:
- The field stops distinguishing interactions. Comparing a placeholder against a real value would never match, so http-vcr redacts the incoming request the same way before matching, leaving the same placeholder on both sides (see Matching Requests). Two recordings that differ only in a one-way redacted field become indistinguishable.
- Application code receives the placeholder at replay time. That’s fine for a field the test only asserts on (
customer.email), and not fine for a token the application reads out of the response and sends in its next request (refresh_token). For anything in that second category, pass a value provider.
Opting out of the default header redaction
The four headers redacted by default are covered at the top of this page.
That default redaction is one-way — the library never knew the real value — so those four headers stop being a distinguishing factor for matching, like any other one-way rule. To match on one of them, opt it out of redaction:
// opt-out, for a test that specifically verifies the auth header itself
$vcr->includeSensitiveHeaders(['Authorization']);
This doesn’t replace vendor/bin/http-vcr scan-secrets — that command scans for secrets outside this default set (custom headers, tokens embedded in a body or query string). The default redaction only covers the one case common enough not to require any setup at all.
Project-wide redaction
redact() on a VcrClient instance covers that instance’s cassette. For a secret common to every cassette in the project — a company-wide proxy token, say, not something tied to one specific provider — declaring it once in http-vcr.php avoids repeating the same call everywhere:
return HttpVcr\Config::create(
// ...
redact: ['<COMPANY_PROXY_TOKEN>' => fn () => $_ENV['COMPANY_PROXY_TOKEN']],
);
This is deliberately flat — it doesn’t key by provider, even though providers are a core concept the config already knows about. A Provider carries two things (hosts and requiresEnv), that’s enough for everything else built on it, and the case for adding a third has a trivial workaround below; it’s on the roadmap, not ruled out. For a secret specific to one provider (a SHOPIFY_API_KEY used across many Shopify tests), the auto-redacted Authorization header usually already covers it; where it doesn’t (the secret shows up in a body or query string instead), the recommended pattern is a small base test case in your own project rather than a new library feature:
abstract class ShopifyTestCase extends TestCase
{
protected function setUp(): void
{
parent::setUp();
$this->vcrClient()->redact('<SHOPIFY_API_KEY>', fn () => $_ENV['SHOPIFY_API_KEY']);
}
}
setUp() runs after the PHPUnit extension has already constructed and injected VcrClient for the test, so it’s still unfrozen at that point — see PHPUnit Integration for the exact lifecycle.
Recipe: redacting IP addresses
Not a separate mechanism — the same helpers as above, applied to wherever an IP address actually shows up.
For a value that varies at runtime (a response’s last_login_ip, a request’s X-Forwarded-For), the field-targeted helpers apply, same as any other field — the placeholder they generate follows the same readable <REDACTED-...> convention as the built-in header redaction, not a real-looking address:
$vcr->redactHeader('X-Forwarded-For');
$vcr->redactJsonField('/user/last_login_ip');
For a fixed, known value you want out — your own static outbound IP, say, appearing literally in a body or header — redact() lets you pick the placeholder yourself. That’s the place to reach for an address from RFC 5737 (192.0.2.1, 198.51.100.1, 203.0.113.1) instead of a random-looking one: those ranges are permanently reserved for documentation and never route on the real internet, so the placeholder can’t be mistaken for an address that happens to still work.
$vcr->redact('203.0.113.1', fn () => $_ENV['MY_SERVER_IP']);
Not enabled by default: unlike the four auth-related headers above, IP addresses aren’t a near-universal credential-shaped risk in every cassette, so there’s no default pattern redacted automatically — only what’s declared explicitly, same as any other redact*() call.
Locked Interactions
Auto re-record (staleAfter, forced recording via VCR_ERASE_TAPE) assumes refreshing a recording is safe. For a GET, that’s usually true. For a mutating request — creating an order, charging a payment, deleting a resource, anything with a real side effect or a one-time token that can’t be replayed — it usually isn’t. Locking protects exactly that case.
It’s the same idea as the physical write-protect tab on a VHS tape or cassette: VCR_ERASE_TAPE erases the tape, but not the part of it that’s protected.
Locking one interaction
vendor/bin/http-vcr lock shopify/checkout --interaction=2
This sets "locked": true on interaction #2 in shopify/checkout.json. The same thing can be done by hand, directly in the JSON file — the CLI command exists for convenience, not because it’s the only way in.
Once locked, that interaction never generates a real HTTP request again, no matter what:
- Under forced recording, it’s excluded from the cassette truncation and keeps being replayed from what’s already recorded, while everything else in the cassette refreshes normally.
VCR_ERASE_TAPE— whether it names this cassette directly, is set toall, or narrows to a single provider — andVCR_ALLOW_RECORDING=1don’t override it. Locking sits above both in precedence — see the Environment Variables reference for the full table.
That’s also why forced recording still runs the matchers even after emptying a cassette: it has to recognize that an incoming request is aimed at a locked interaction before it can leave that interaction alone. Locked survivors keep their relative order at the front of the file, and freshly recorded interactions are appended after them — relevant if the cassette is also under StrictMode::InOrder, which will expect that order on the next run.
# refreshes every Shopify interaction, wherever it lives... except the locked ones
SHOPIFY_API_KEY=xxx VCR_ERASE_TAPE=@shopify vendor/bin/phpunit
Unlocking
Always explicit, never automatic:
vendor/bin/http-vcr unlock shopify/checkout --interaction=2
— or delete the "locked": true line from the JSON by hand.
There’s no signature or checksum protecting the locked field itself. That’s deliberate, and consistent with the rest of the cassette format being plain, hand-editable JSON: the real protection is that a change to locked shows up as a visible line in a pull request diff, the same way any other cassette change does.
Locking a whole cassette from the test itself
For a cassette that’s entirely about a sensitive operation, locking every interaction one by one is unnecessary ceremony. Declare it once, in the test:
#[UseCassette('shopify/checkout', locked: true)]
public function testCheckoutCreatesOrder(): void { /* ... */ }
This has the same effect as locking every interaction in the file, but with two things the data-only version doesn’t give: it’s visible right in the test code without opening the cassette, and it still holds even if the locked field in the JSON gets accidentally reverted — the code-level lock takes precedence over what’s in the data.
Use the JSON field for surgical, per-interaction locking, when a cassette mixes safe-to-refresh reads with one sensitive write. Use the attribute when the whole cassette is sensitive.
A fully locked cassette plus VCR_ERASE_TAPE is a no-op by construction: nothing is erased, nothing is requested, and the file is unchanged when the run finishes. The run says so explicitly (cassette fully locked, VCR_ERASE_TAPE had no effect) rather than leaving it looking like the variable was ignored.
Strict & Sequential Mode
By default, http-vcr doesn’t care whether every recorded interaction actually got replayed, or in what order. StrictMode turns that into an assertion.
StrictMode::AllPlayed
Fails when the cassette closes if any recorded interaction was never replayed:
new VcrClient($inner, cassette: 'shopify/get-product', strictMode: StrictMode::AllPlayed);
This catches drift in the opposite direction from a missing match: instead of “the code asked for something the cassette doesn’t have,” it’s “the cassette has something the code never asked for” — usually a sign a code path got removed, or a cassette was recorded once and never trimmed down.
A repeatablePlayback interaction never gets consumed, so “unplayed” means something slightly different for it: it counts as played once it’s been replayed at least once. One that nothing ever asked for still fails AllPlayed — that’s precisely the signal this mode is for.
It’s an assertion about one file, which presumes that file belongs to this test alone — see What one cassette covers.
With scoped cassettes, this is checked per scope file, not aggregated across a whole test run. If a test touches both a 2024-01 and a 2024-04 scope, each of those two physical files has to independently close with zero unplayed interactions — treating them as one shared pool would hide which specific file has the leftover.
StrictMode::InOrder
Fails unless interactions are replayed in exactly the order they were recorded:
new VcrClient($inner, cassette: 'shopify/checkout', strictMode: StrictMode::InOrder);
This is for code where the sequence of calls matters, not just which calls happen — a checkout flow that has to create a cart before it can add an item to it, for example.
repeatablePlayback interactions are exempt from the ordering check: only the order of non-repeatable interactions relative to each other counts. A repeatable interaction — typically the target of retry logic — can be replayed multiple times, anywhere in the sequence, without breaking InOrder for everything else.
In a session that records
Both modes assert on how the existing recording got replayed, so a session that recorded anything — ExtendCassette picking up a new request, RecordIfAbsent creating the cassette, a forced re-record — checks only the interactions that were in the cassette when the session opened, ignoring the ones it added along the way. Otherwise AllPlayed would pass trivially after every recording run (a just-recorded interaction was, by definition, “played”) and InOrder would be comparing a sequence against a list it built itself in the same run.
Under forced recording, “in the cassette when the session opened” means after the truncation — truncation is part of opening, not something that happens afterwards. So VCR_ERASE_TAPE=<cassette> on a cassette with no locks leaves both modes with an empty set to check (they pass trivially), while whatever the selector spared is checked exactly as usual: locked interactions, and — when the selector named a provider — the other providers’ traffic that kept replaying. That’s the intent — there’s nothing to assert about a recording the same run just erased.
One wrinkle specific to InOrder and a partial re-record: survivors are written back at the front of the file and freshly recorded interactions appended after them, so refreshing one provider inside a multi-API cassette — a sync/order-flow carrying both Shopify and Zendesk traffic — reorders it relative to the sequence the code under test actually performs. If such a cassette is under InOrder, look at the file after a partial refresh — or re-record the whole thing (VCR_ERASE_TAPE=<cassette>, no @provider), which restores the natural execution order.
When the check runs
Both modes are checked by VcrClient::close(), which also releases the recording lock. The PHPUnit integration calls it in its after-test hook; a hand-built client is closed by whatever built it:
$vcr = new VcrClient($inner, cassette: 'shopify/checkout', strictMode: StrictMode::AllPlayed);
// ... exercise the code under test ...
$vcr->close();
The destructor releases the lock too, but never raises a strict-mode failure: it runs at a moment nothing chose — often while another exception is already on its way up, where an assertion would bury the actual failure.
Setting it per test, not just globally
Both examples above configure strictMode on the VcrClient constructor directly, which applies for as long as that instance lives. With the PHPUnit attribute, the same thing is set per test:
#[UseCassette('shopify/checkout', strictMode: StrictMode::InOrder)]
public function testCheckoutCreatesOrderThenCapturesPayment(): void { /* ... */ }
That’s usually the better default than turning it on globally: AllPlayed/InOrder tend to matter for one specific, well-understood action, not the whole suite — a blanket AllPlayed would just produce false alarms on every unrelated cassette that happens to carry an unused interaction from an earlier refactor.
Auto Re-record (staleAfter)
APIs change. A cassette recorded six months ago might no longer reflect what the real endpoint returns today — and there’s no way to know that from a passing test, since the test is, by design, no longer talking to the real API. staleAfter flags that without turning it into a build gate no one asked for.
new VcrClient($inner, cassette: 'shopify/get-product', staleAfter: new DateInterval('P7D'));
What “stale” means
Staleness is tracked per interaction, not per file — an interaction is stale when now() - interaction.recordedAt > staleAfter. A cassette as a whole counts as stale if it has at least one stale interaction, but stale reports the specific interactions, not just the file. This matters in ExtendCassette mode, where a cassette grows over time: one interaction recorded months ago shouldn’t make an otherwise-fresh file look entirely stale.
It doesn’t fail the build by default
Checking against now() is inherently non-deterministic between runs — the same commit can pass in a merge-request pipeline and fail an hour later on main, purely because staleAfter was crossed in between. So by default, staleness is informational only:
vendor/bin/http-vcr stalelists what’s stale, meant to run as a separate, non-blocking CI step (“cassettes to refresh”), not something that fails the build- the test keeps using the “stale” cassette exactly as before
Opting into enforcement
For teams that want it hard-enforced anyway:
VCR_ENFORCE_STALE_CHECK=1 vendor/bin/phpunit
This makes a stale cassette actually fail the test, with a StaleCassetteException naming the interactions that outlived the threshold — a deliberate trade of some non-determinism for a forced re-record cadence. The check happens when the cassette is opened, so a run that is going to stop stops before the code under test is halfway through on replayed data. If this is turned on, set it identically in both merge-request and main-branch pipelines to avoid the two drifting apart.
For a one-off run that must pass regardless (a hotfix, say), override it:
VCR_IGNORE_STALE_CASSETTES=1 vendor/bin/phpunit
This treats every cassette as fresh, no matter what recordedAt says — see the precedence table for how it interacts with everything else, including locked interactions, which sit above it.
Testing your own staleAfter
“Now” comes from an injectable PSR-20 clock (Psr\Clock\ClockInterface), defaulting to SystemClock. Any PSR-20 implementation works — Symfony’s MockClock, lcobucci/clock, your own — and FrozenClock ships with the package so that testing this needs no extra dependency:
use HttpVcr\Clock\FrozenClock;
new VcrClient(
$inner,
cassette: 'shopify/get-product',
staleAfter: new DateInterval('P7D'),
clock: new FrozenClock(new DateTimeImmutable('2026-08-20T12:00:00Z')),
);
That lets a test assert what happens on either side of the threshold without waiting out real time or mocking global functions.
Two independent axes
staleAfter is about elapsed time. Scoping is about a contract change visible in the URL. They’re independent and can both be active: scope decides which file matters, staleAfter makes sure that file doesn’t go stale even if the version never changes.
Setting it per test, not just globally
The constructor form above applies for as long as that VcrClient instance lives. With the PHPUnit attribute, the same threshold is set per test:
#[UseCassette('shopify/get-product', staleAfter: new DateInterval('P7D'))]
public function testGetProduct(): void { /* ... */ }
This is useful when different integrations change at different rates — a fast-moving pricing endpoint might want a week, a stable product catalog might not need checking at all.
There’s one wrinkle staleAfter has that strictMode doesn’t: strictMode only matters while a test is actually running, so it doesn’t need to be visible to anything else. staleAfter, on the other hand, is also read by vendor/bin/http-vcr stale — a CLI command that reports stale interactions without running any tests. Since the threshold can live only in an attribute, the CLI reads it the same way tests reads everything else about #[UseCassette]: by parsing the test files’ AST, not by executing them. A cassette that no test declares staleAfter for is simply never checked — that’s the correct, opt-in default, not a gap. If two different tests declare different staleAfter values for the same cassette name, the CLI reports the conflict rather than silently picking one.
Scoping Cassettes by URL
Some APIs version themselves in the URL — Shopify puts a date in the path (/admin/api/2024-01/...), others use /v2/, /v3/. When application code moves from one version to the next, you want a clear, readable error — “no cassette recorded for this version” — not a generic “none of these interactions match,” and definitely not a silent match against the wrong, outdated interaction.
The default UriMatcher already partly handles this — a different URL segment simply won’t match — but that mixes the old and new version into one file and doesn’t say why nothing matched. Scoping resolves this explicitly, at the level of which cassette file gets used, not just at the matching level.
interface CassetteScopeResolverInterface {
public function resolve(RequestInterface $request): ?string;
}
Built-in resolvers
-
NullScopeResolver(default) — no scoping, unchanged behavior. -
RegexUrlScopeResolver— extracts a scope from the URL via a named capture group:new RegexUrlScopeResolver('#/api/(?<scope>\d{4}-\d{2})/#') // Shopify: date new RegexUrlScopeResolver('#/v(?<scope>\d+)/#') // version number -
CallbackScopeResolver— arbitrary logic, e.g. reading the version from a header instead of the URL (Accept: application/vnd.api+json;version=3).
A resolver applies per request, not per cassette: a URI the pattern doesn’t match is unscoped and belongs in the cassette’s own file. That’s what makes a resolver safe on a cassette that also carries traffic the versioning doesn’t apply to — an OAuth token endpoint outside the versioned path, say.
What happens on a version bump
A cassette named ProductsTest__getProduct at scope = 2024-01 is stored as ProductsTest__getProduct.2024-01.json. Once the application starts calling 2024-04, VcrClient computes the new scope and doesn’t find a file for it. What happens then depends on why it couldn’t just record one — the exception names the actual cause, following the same rule as everywhere else:
PlaybackOnly — the declared mode rules recording out, so CassetteNotFoundException. No environment variable would change that, and the message doesn’t pretend otherwise:
No cassette recorded for scope "2024-04" (base: ProductsTest__getProduct).
Existing scopes: 2024-01. Mode is PlaybackOnly, which never records —
record it under RecordIfAbsent, or add the missing scope by hand.
RecordIfAbsent / ExtendCassette with recording blocked — RecordingNotAllowedException, because that’s the real reason: the identical run with recording allowed would have recorded the new scope and passed.
Cannot record cassette "ProductsTest__getProduct" (scope "2024-04"):
recording is disabled by CI detection (CI=true is set, VCR_ALLOW_RECORDING
is not). Existing scopes: 2024-01.
RecordIfAbsent / ExtendCassette with recording allowed — no exception at all: a new file is recorded for the new scope, exactly like the first recording of any cassette.
The Existing scopes: line is common to both failures — it’s the part that actually carries the information here (“2024-01 is on disk, the code is asking for 2024-04”), whichever exception ends up being thrown.
Scopes become filenames
A scope is appended to the cassette filename, and CallbackScopeResolver can return anything at all — a header value, whatever a closure computes. So a scope is sanitized before it’s used as a path component: characters outside [A-Za-z0-9_.-] become _, and a result that’s empty, ., .., or starts with a dot is rejected outright rather than turned into a hidden file or a path escape. A scope is always a single path segment; a / inside it is just another character to replace, not a directory separator.
The cassette name is different — there, / is meaningful (shopify/get-product lives in a shopify/ subdirectory), so each segment is sanitized on its own and the resolved path is checked to still be inside the cassette directory.
Two independent axes
This is a second, independent axis from staleAfter: staleAfter is about elapsed time, scoping is about a contract change visible in the URL itself. Both can be active together — scope decides which file matters, staleAfter makes sure that file doesn’t go stale even if the version never changes.
Transport Errors
A 4xx or 5xx HTTP response is just a normal, valid interaction with a status code — nothing special. A transport failure — a timeout, DNS failure, connection refused — is different: the request never got a response at all, and PSR-18 represents that as an exception instead of a ResponseInterface.
Recording transport failures is opt-in
By default, when a real request during recording throws a PSR-18 client exception (NetworkExceptionInterface / RequestExceptionInterface), http-vcr doesn’t persist it. The exception passes through VcrClient::sendRequest() unchanged and nothing is written to the cassette — a transient network blip shouldn’t become a permanent part of a regression test.
To deliberately record one — for testing an application’s retry/error-handling code against a deterministic network failure, without actually severing a connection on every CI run:
new VcrClient($inner, cassette: 'shopify/get-product', recordTransportErrors: true);
The interaction is then stored as a special variant with "outcome": "error" instead of a response — a category (network or request), a message, and the original exception’s class name, kept purely as diagnostic metadata (see below, not for reconstruction).
That stored message goes through redaction like everything else in the interaction — HTTP client exceptions habitually quote the full request URL, and a URL is exactly where ?api_key=… tends to live.
Replay throws http-vcr’s own exception, not the original class
PSR-18 only guarantees the interfaces (NetworkExceptionInterface, RequestExceptionInterface), not how any particular client’s exception classes are constructed. There’s no general, safe way to rebuild an arbitrary GuzzleHttp\Exception\ConnectException — or any other client library’s exception — from what’s stored on disk.
So replay throws VcrNetworkException / VcrRequestException — http-vcr’s own classes, implementing the relevant PSR-18 interface. Application code that catches by the PSR-18 interface, as PSR-18-aware code should, behaves identically either way. Code that catches a specific Guzzle exception class directly was never something http-vcr could safely reproduce regardless — the original class name is recorded for diagnostics and tooling, not for reconstruction at replay time.
Storage & Formats
Two separate questions, answered by two separate interfaces: where a cassette lives (the persister) and what shape it has on disk (the serializer). Neither is something most projects need to touch — the defaults are the filesystem and JSON — but both are swappable, and the split is what keeps a compressed or database-backed store from needing any change in the record/replay core.
new VcrClient(
$inner,
cassette: 'shopify/get-product',
persister: new FilesystemCassettePersister(),
serializer: new JsonCassetteSerializer(),
);
Set either one project-wide in http-vcr.php instead, if it should apply everywhere.
Serializers
Two serializers are canonical, meaning they carry http-vcr’s full domain model — schemaVersion, outcome, bodyRef, repeatablePlayback, locked — and are supported across the whole cassette lifecycle, schema migration included.
| Serializer | Extension | Notes |
|---|---|---|
JsonCassetteSerializer | .json | Default. Readable diffs in a pull request, no dependencies. |
YamlCassetteSerializer | .yaml | Opt-in, requires symfony/yaml. The convention teams coming from Ruby VCR, vcrpy, or go-vcr will recognize. |
new VcrClient($inner, cassette: 'shopify/get-product', serializer: new YamlCassetteSerializer());
The interface is small:
interface CassetteSerializerInterface {
public function serialize(Cassette $cassette, ?SidecarBodies $bodies = null): string;
public function deserialize(string $content, ?SidecarBodies $bodies = null): Cassette;
public function fileExtension(): string; // 'json', 'yaml' — no leading dot
}
$bodies is where bodies past the inline threshold go, since a body large enough to leave the file has to be written somewhere; passing null keeps every body inline whatever its size. The cassette manager supplies one, because it is what knows which cassette file is open and therefore what the sidecars beside it are called.
The unit of exchange is a Cassette — a schemaVersion plus a list of Interactions, with no I/O of its own — rather than a bare array of interactions. That’s what lets a serializer carry the version in both directions: schemaVersion is a property of the file, not of any interaction, so a serializer that only ever saw a list would have to emit the version from a hardcoded constant and throw it away on read, leaving nowhere for the migration path to live.
fileExtension() exists because the persister deliberately knows nothing about formats: it stores bytes under a key, and it’s the cassette manager that turns a cassette name plus a serializer’s extension into that key.
Persisters
interface CassettePersisterInterface {
public function read(string $key): ?string;
public function write(string $key, string $content): void;
public function delete(string $key): void;
public function exists(string $key): bool;
/** @return iterable<string> names (without extension) of cassettes stored in that format */
public function list(string $extension, string $prefix = ''): iterable;
}
A $key includes the format’s file extension; the names list() returns don’t. The extension is passed in rather than known by the persister, since a persister stores keyed bytes and has no idea which serializer is in play — the caller (the cassette manager, or the CLI) always has one at hand.
FilesystemCassettePersister is the default: one file per cassette, under cassetteDirectory, with the cassette name as a relative path inside it.
A few notes for anyone writing their own:
delete()isn’t optional. Three documented behaviors need it: cleaning up orphaned sidecars, removing a sidecar when a body shrinks below the inline threshold, and tidying up after a failed write.list()must return only entries stored under the extension it was given — sidecar.binfiles and.cassette-lockfiles go through the same persister, andstale/scan-secretswould otherwise try to deserialize raw bytes as a cassette.- A persister that can’t meaningfully enumerate (a database-backed one, say) returns an empty iterator from
list(). The CLI then reports “this persister doesn’t support enumeration” rather than silently finding nothing. - Adding a storage variation doesn’t need a new persister at all: a decorator around an existing one — a
GzipCassettePersistercompressing on the way through, for instance — composes cleanly, because the contract is just keyed bytes.
Concurrency, atomic writes, and the lock-file mechanism are the filesystem persister’s business and are documented in the Cassette Format Reference.
HAR: import and export, not a storage format
HAR (HTTP Archive) is an open standard and genuinely useful for exchanging traffic with tools outside http-vcr — the Network tab in Chrome or Firefox DevTools, Postman, Charles Proxy. It’s supported for exactly that, and it is deliberately not a CassetteSerializerInterface:
use HttpVcr\Import\HarCassetteImporter;
use HttpVcr\Import\HarCassetteExporter;
(new HarCassetteImporter())->import('captured.har', 'shopify/get-product');
(new HarCassetteExporter())->export('shopify/get-product', 'shopify.har');
Import converts a HAR capture into a JSON cassette — a one-off starting point, usually to avoid recording something by hand. Export goes the other way, for sharing with external tooling.
The reason it isn’t a serializer: HAR has no natural home for concepts specific to http-vcr — schemaVersion, outcome: "error" for a transport failure, bodyRef, repeatablePlayback, staleAfter/recordedAt. Forcing them into someone else’s archival standard would mean either quietly departing from the HAR spec or trimming http-vcr down to the intersection of the two. Import and export accept that a round trip through HAR is lossy for those fields, which is fine for an interchange format and would not be fine for the file a test suite depends on. (Polly.js, in the JS ecosystem, does use HAR as its storage format — a deliberate difference, and one it can afford because its model has no equivalent of those fields.)
Format versioning
Every serialized cassette carries a top-level schemaVersion, starting at 1. Cassettes live a long time in a repository, so migration has to be possible from day one rather than retrofitted: deserialize() checks the version, throws CassetteFormatException with an “upgrade http-vcr” message on anything newer than it understands, and runs older-but-supported versions through an incremental, per-field upgrade path before the rest of the core sees them.
HAR keeps its own external standard and isn’t versioned by http-vcr.
Guzzle
Use the middleware. It sits inside Guzzle’s handler stack, so it sees every request no matter which of Guzzle’s two APIs the calling code used:
use GuzzleHttp\Client;
use GuzzleHttp\HandlerStack;
use HttpVcr\Bridge\Guzzle\VcrMiddleware;
$stack = HandlerStack::create();
$stack->push(VcrMiddleware::create($vcr));
$guzzle = new Client(['handler' => $stack]);
The plain decorator — new VcrClient(new GuzzleHttp\Client(), cassette: '…') — also works, and needs no middleware at all, but only if every call in the codebase goes through sendRequest(). That’s a narrower condition than it sounds, and the next section is about why.
Why: Guzzle has two APIs
GuzzleHttp\Client implements PSR-18’s sendRequest(), but it also has its own, older, richer API: request(), the magic verb methods (get(), post(), …), requestAsync(), and Pool for concurrent requests. Both APIs route through the same internal handler stack, but only one of them — sendRequest() — is visible to the decorator.
// invisible to VcrClient — it never touches sendRequest()
$response = $guzzle->get('https://shop.myshopify.com/admin/api/2024-01/products/123.json');
If any part of a codebase calls Guzzle’s native API directly on the underlying client instead of going through the wrapped VcrClient, that call bypasses recording and replay entirely — a real request to a real API, with no cassette involved, and no warning that it happened.
The middleware in full
VcrMiddleware sits below both APIs, so every entry point is covered:
use GuzzleHttp\Client;
use GuzzleHttp\HandlerStack;
use HttpVcr\Bridge\Guzzle\VcrMiddleware;
use HttpVcr\VcrClient;
// note: no inner client here — the middleware supplies one, see below
$vcr = new VcrClient(inner: null, cassette: 'shopify/get-product');
$stack = HandlerStack::create();
$stack->push(VcrMiddleware::create($vcr));
$guzzle = new Client(['handler' => $stack]);
$guzzle->get('...'); // recorded / replayed
$guzzle->sendRequest($request); // recorded / replayed
$guzzle->requestAsync('...'); // recorded / replayed
If a codebase already has other middleware on its HandlerStack (retry, logging), push VcrMiddleware onto that same stack — it doesn’t replace or duplicate any of it, it’s just one more layer.
Where it sits among other middleware
Guzzle applies a handler stack from the bottom up, so push order decides which side of the cassette each middleware ends up on:
- pushed before
VcrMiddleware(including everythingHandlerStack::create()brings along — redirects,http_errors, cookies) — sits above it and treats a replayed response exactly like one off the wire; - pushed after it — sits between the cassette and the transport, so it only ever sees requests that are actually being recorded.
Retry and logging usually belong above; anything that signs or instruments the real connection belongs below.
Where the real request goes
The middleware doesn’t let VcrClient use its own inner client for recording. It wraps the next handler in the stack as a PSR-18 client and hands that over per request:
$vcr->withInner($nextHandlerAsPsr18Client)->sendRequest($request);
withInner() returns a new VcrClient with the same cassette session and configuration. Two things fall out of that, both of which you’d otherwise have to debug the hard way: a real request travels through the rest of the stack (retry, logging) instead of jumping around it, and passing the same Guzzle client as the inner client can’t set off infinite recursion through the middleware. VcrClient is constructed exactly the same way for the decorator and for the middleware — one shape to learn, and #[UseCassette] maps onto both.
Request options at replay time
Guzzle options that only make sense for a real transport — timeout, proxy, cert, sink, on_stats — are ignored when a response comes from a cassette; there’s no connection for them to apply to. Most of those are invisible to application code anyway. The two that aren’t: sink (writing the response body straight to a file) and on_stats (transfer metrics) will not do their thing on a replayed request.
When the plain decorator is enough: when the whole codebase disciplines itself to Psr\Http\Client\ClientInterface and sendRequest(), with no exceptions — which is worth checking rather than assuming, since a single $client->get() somewhere is enough to punch a hole in it. Shopify’s official SDK is a worked example of an SDK that passes that test even though it builds its own Guzzle client internally.
One more difference worth knowing
Guzzle’s own documentation notes that sendRequest() — the PSR-18 path — does not follow redirects automatically, unlike request()/requestAsync(), in order to meet PSR-18’s stricter requirements. So even code that goes through PSR-18 exclusively may see a plain 3xx response where “normal” Guzzle usage would have followed it transparently. http-vcr records and replays exactly what’s visible at the PSR-18 boundary — a single response, not a followed redirect chain.
Real-world example
For a concrete case study of the decorator-vs-middleware question against an actual Guzzle-based SDK — including a case where the SDK builds its own client internally and the decorator still turns out to be enough — see Shopify’s Official PHP SDK.
A short test using the middleware, with a history handler above the cassette and one below, is in examples/guzzle-middleware.php.
Symfony HttpClient
Symfony’s HTTP client has two faces, and only one of them is PSR-18.
Psr18Client — works with zero setup
use Symfony\Component\HttpClient\Psr18Client;
$vcr = new VcrClient(new Psr18Client(), cassette: 'shopify/get-product');
Psr18Client implements Psr\Http\Client\ClientInterface directly and has no parallel native API bypassing it — unlike Guzzle, there’s nothing to route around here (see Guzzle for that problem). Anything typed against PSR-18 works without any bridge.
The native HttpClientInterface — needs the bridge
Symfony\Contracts\HttpClient\HttpClientInterface is Symfony’s idiomatic way of making HTTP calls — autowiring it in services is the norm in a Symfony app, not the exception — but it is not PSR-18. Different signature:
interface HttpClientInterface {
public function request(string $method, string $url, array $options = []): ResponseInterface;
public function stream($responses, ?float $timeout = null): ResponseStreamInterface;
public function withOptions(array $options): static;
}
with its own, richer ResponseInterface (streaming, getInfo(), chunked reads). VcrHttpClient bridges this:
use HttpVcr\Bridge\Symfony\VcrHttpClient;
$client = new VcrHttpClient($vcr); // implements Symfony's HttpClientInterface
public function __construct(
VcrClient $vcr,
?RequestFactoryInterface $requestFactory = null,
?UriFactoryInterface $uriFactory = null,
) {}
Internally, request() builds a PSR-7 request from $method/$url/$options via PSR-17 factories. This bridge is the one place that needs RequestFactoryInterface and UriFactoryInterface on top of the response/stream factories the core uses — which is why they’re arguments here rather than on VcrClient, whose own constructor would otherwise carry two values it never uses and would have to expose getters for. Left as null, they’re resolved the same way as every other PSR-17 factory — as is the stream factory the bridge puts a request body on, which has no argument of its own because there’d be nothing to say with it that Config doesn’t already say. It then sends the request through the wrapped VcrClient and maps the PSR-7 response back onto Symfony’s response shape — using Symfony’s own Symfony\Component\HttpClient\Response\MockResponse for the return value, since a replayed cassette response is by definition a complete, known-in-advance response, which is exactly what MockResponse is built for.
A MockResponse on its own is a description of a response, not a usable one — it has to be materialized by a client that attaches the request and transfer info to it. So the bridge keeps a MockHttpClient internally and returns what that produces, rather than handing back the MockResponse directly; otherwise getInfo() and friends behave unpredictably. stream() delegates to the same MockHttpClient.
The interface has a third method, withOptions(), which the bridge implements the way Symfony’s own clients do: it returns a new VcrHttpClient with the default options merged in.
Like the Guzzle bridge, this doesn’t duplicate any record/replay logic — it only translates the shape of the call. All of the actual behavior lives in VcrClient.
A service autowiring HttpClientInterface, tested through the bridge, is in examples/symfony-http-client.php.
Laravel
Laravel’s Http facade is a thin wrapper around Guzzle — see Guzzle for what that means for the decorator. Install the bridge package and every Http:: call in a test with #[UseCassette] is recorded and replayed, with nothing to wire up. If you’d rather not add a package, there’s a manual recipe at the bottom of this page.
The bridge package
For the same zero-ceremony feel as Laravel’s built-in Http::fake(), install the Laravel bridge — a separate package that depends on http-vcr:
composer require --dev mtk3d/laravel-http-vcr
It pulls in mtk3d/http-vcr itself, so that’s the only line you need. It requires Laravel 11 or newer — expressed in the package’s own require, so Composer declines to install it on anything older rather than letting it fail later.
The package auto-registers itself (extra.laravel.providers in its composer.json — no manual entry in bootstrap/providers.php), and:
- publishes
config/http-vcr.php(cassetteDirectorydefaults tobase_path('tests/Cassettes')) - registers the same commands as
vendor/bin/http-vcras Artisan commands, prefixed withvcr:(see CLI Reference for why):vcr:stale,vcr:providers,vcr:tests,vcr:scan-secrets,vcr:lock/vcr:unlock - hooks into
Illuminate\Http\Client\Factory::globalOptions()— Laravel’s own public API for setting Guzzle options on every request made through theHttpfacade, regardless of call site — to install aHandlerStackcarryingVcrMiddleware. If the application already sets its ownhandler, the bridge pushes onto that stack instead of replacing it (from abooted()callback, so it works regardless of provider registration order) - narrows the default for
VCR_ALLOW_RECORDINGwithapp()->environment(): when the variable isn’t set explicitly, recording is allowed only if the environment islocal/testingand the framework-agnostic CI detection found nothing. The bridge only ever tightens that default, never loosens it — an environment check on its own would be worse than useless here, since tests on CI run withAPP_ENV=testingand would end up more permissive than on a plain PHP project. An explicitly set variable still wins over both - only installs the global hook in the
localandtestingenvironments. The package is a dev dependency, so it usually isn’t present in production at all — this is the belt to that suspenders - warns, in the
testingenvironment, if http-vcr’s PHPUnit extension isn’t registered inphpunit.xml. Your test calls theHttpfacade rather than$this->vcrClient(), so the trait’s own guard never runs — without this check a missing extension would mean every#[UseCassette]test silently talking to the real API
#[UseCassette('shopify/get-product', requiresEnv: ['SHOPIFY_API_KEY'])]
public function testGetProduct(): void
{
$product = Http::get('https://shop.myshopify.com/admin/api/2024-01/products/123.json')->json();
$this->assertSame('T-Shirt', $product['title']);
}
No Http::fake(), no manual client construction — every Http:: call is intercepted for the duration of the test, no matter which method on the facade is used.
Who installs the hook, and when
Not the PHPUnit extension — it can’t. Its hook fires before setUp(), and a Laravel application is created inside setUp() (TestCase::createApplication()) and destroyed again in tearDown(), so anything the extension set on Factory would land on the previous test’s container, or on one that doesn’t exist yet. The work is split instead:
- The PHPUnit extension builds the test’s
VcrClientand puts it in a process-level handle (CurrentCassetteSession— the same one$this->vcrClient()reads from), then closes the cassette and clears the handle afterwards. It knows nothing about Laravel; this is the same path Guzzle and Symfony use. HttpVcrServiceProviderinstalls theglobalOptions()handler once per application boot — which, in tests, means inside eachsetUp(). TheVcrMiddlewareit installs consults the handle at request time: a cassette session is active, so the request goes through it; no session, so the request passes straight to the next handler untouched. A test without#[UseCassette]behaves exactly as if the bridge weren’t installed.
That handle is process-level state, which the core deliberately avoids. It lives in the bridge, not the core, and it’s forced by what’s being intercepted: Http is a facade — a global service locator — so the only way to take over a call without touching the call site is a pointer to “the currently active session.” The same hook that opens and closes the cassette sets and clears it, so it can’t leak between tests.
Why not
globalRequestMiddleware()? Laravel also exposesglobalRequestMiddleware()/globalResponseMiddleware(), which look like the obvious hook and aren’t: they’re transformers. One takes a request and must return a request, the other takes a response and must return a response. Neither can short-circuit a call and serve a response from a cassette instead of going to the network — the one thing a VCR needs from a hook. Handler-stack middleware can, which is whyHttp::fake()uses the same mechanism.
Without the package: the manual recipe
// in a test's setUp(), or a testing-only service provider
Http::globalOptions(['handler' => $stack]);
where $stack is a Guzzle HandlerStack with VcrMiddleware::create($vcr) pushed onto it (see Guzzle). Same interception, zero additional dependencies — but it’s configuration you wire by hand in every test or in a testing-only service provider, and you’re responsible for tearing it down again. The whole thing, as a base test case to copy, is in examples/laravel-http-recipe.php.
Http::withOptions([...])looks like it would do the same thing and doesn’t: it returns aPendingRequestconfigured for one call, so as a standalone statement it configures an object that’s then thrown away. Use it only when you go on to make the request on it (Http::withOptions([...])->get(...)). The application-wide form isHttp::globalOptions(), added in Laravel 11.
Other PSR-18 Clients
Works with zero setup
Anything implementing Psr\Http\Client\ClientInterface and used purely through that interface:
| Client | Notes |
|---|---|
GuzzleHttp\Client (Guzzle 7+) | only for calls made through sendRequest() — see Guzzle for why that matters |
Symfony\Component\HttpClient\Psr18Client | no caveats — see Symfony |
Http\Client\Curl\Client, Http\Client\Socket\Client (php-http) | native PSR-18 |
Buzz\Client\* (kriswallsmith/buzz) | native PSR-18 |
$vcr = new VcrClient(new Http\Client\Curl\Client(), cassette: 'shopify/get-product');
Deliberately out of scope
- Amp (
amphp/http-client), ReactPHP (react/http) — a different paradigm entirely: event-loop based, their own promise implementations, not PSR-18’s synchronoussendRequest(). Doesn’t fit the decorator architecture. A possible separate project, not part of this one. - Raw
curl_exec/ stream contexts — http-vcr works by decorating a PSR-18 client, and code callingcurl_exec(orfile_get_contentswith a stream context) has no such boundary to decorate. Routing those calls through a PSR-18 client first puts them in the zero-setup case above.
PHPUnit
Setup
Register the extension in phpunit.xml, once:
<extensions>
<bootstrap class="HttpVcr\Bridge\PHPUnit\Extension"/>
</extensions>
PHPUnit has no auto-discovery for extensions, so this is the one step that can’t happen by itself — and skipping it fails quietly in the worst way: #[UseCassette] becomes decoration, nobody reads it, and the test makes real network calls. $this->vcrClient() guards against exactly that: with no extension registered there’s no active session, and it throws telling you to add the block above rather than handing back an unconfigured client.
Nothing else is required. The config file, the cassette directory and the PSR-17 factory all have working defaults.
The attribute
use HttpVcr\Bridge\PHPUnit\UseCassette;
#[UseCassette(
'zendesk-account-a/get-ticket',
mode: RecordMode::RecordIfAbsent,
requiresEnv: ['ZENDESK_ACCOUNT_A_SUBDOMAIN'], // optional — on top of the provider's own, see Providers
locked: true, // optional — see Locked Interactions
strictMode: StrictMode::AllPlayed, // optional, defaults to StrictMode::None — see Strict & Sequential Mode
staleAfter: new DateInterval('P7D'), // optional, defaults to null — see Auto Re-record
)]
public function testGetTicket(): void { /* ... */ }
On a test method or class, PHPUnit’s Extension API registers a hook that creates a configured VcrClient before the test, publishes it for the trait to hand out, closes the cassette after the test, and — under StrictMode::AllPlayed — asserts everything got replayed.
The attribute needs the Extension API, so the bridge supports PHPUnit 10 through 13. (That’s a different range from what http-vcr itself is tested on — see Installation.) On PHPUnit 9 and older there are no such hooks; use the trait’s closure form below instead.
On a class, #[UseCassette] is sugar for applying the identical attribute to every method in that class — not a shared session. Each method still gets its own independent VcrClient (own open/close, own replay-consumption tracking) against the same cassette file. That’s useful when several methods deliberately exercise the same recording (e.g. proving two client bridges behave identically against one cassette), not a way to spread unrelated requests across methods sharing one file — a method that doesn’t replay everything in the cassette will trip StrictMode::AllPlayed just as it would with any other session. A method-level attribute replaces the class-level one entirely rather than merging with it, matching how PHPUnit’s own attributes behave.
VcrClient is a mutable configuration object up until the first sendRequest() of the cassette session — redact() and friends, includeSensitiveHeaders(), beforeRecord()/beforePlayback(), and any matcher added outside the constructor must be registered before that point, or they throw LogicException (see VcrClient Reference). In practice: $this->vcrClient() is already available and unfrozen by the time setUp() runs, since the extension’s hook fires before it — so setUp() (or the first lines of the test body, before any code that triggers a request) is the right place for per-test redaction.
strictMode and staleAfter are both set per test here, not globally — that’s usually what’s wanted: AllPlayed/InOrder tend to matter for one specific, well-understood action rather than the whole suite, and different integrations often go stale at different rates. One difference between the two: stale (see CLI Reference) needs to know each cassette’s staleAfter threshold without running any tests, so it’s read from the attribute via the same AST scan tests uses — strictMode has no such requirement, since it only matters while the test itself is running.
The trait
InteractsWithCassettes has two methods, for two different situations — the first one is used together with the attribute, not instead of it:
use HttpVcr\Bridge\PHPUnit\InteractsWithCassettes;
// the VcrClient the attribute built for this test: the PSR-18 client to pass
// into the code under test, and what per-test redaction is registered on
$vcr = $this->vcrClient();
// a cassette session around a closure, with no attribute involved
$this->useCassette('shopify/get-product', function () {
// ...
});
useCassette() is for PHPUnit 9 and older (no Extension API), for a test that needs two different cassettes, and for tests not written in PHPUnit at all. It accepts the same optional arguments as the attribute: mode, strictMode, staleAfter, requiresEnv, locked. The closure is handed the VcrClient as its argument, and whatever it returns comes back from useCassette().
The trait also closes the session, from an #[After] method it brings with it. That matters for one thing: StrictMode::AllPlayed/InOrder assert at close, and an assertion raised inside the test’s own lifecycle fails that test, while an exception from a PHPUnit event subscriber only ever becomes a runner warning. A test class that declares a cassette without using the trait still gets its lock released and its session cleared by the extension — it just won’t fail on a strict-mode violation.
Both read from HttpVcr\Bridge\PHPUnit\CurrentCassetteSession, the process-level handle the extension puts the test’s VcrClient into and clears afterwards. It’s a public, BC-guaranteed contract, not an internal detail — the Laravel bridge lives in a separate package and consults it at request time to decide whether an Http facade call belongs to an active cassette session. Anything else integrating a framework whose HTTP entry point is global will need the same seam.
Providers
A provider is an external API you can name in a command — most usefully in VCR_ERASE_TAPE=@shopify, to refresh one API’s recordings and leave the rest alone.
You get providers without configuring anything. Every host that appears in your cassettes is implicitly its own provider, named after the host, so this works in a project with no http-vcr.php at all:
VCR_ERASE_TAPE=@shop.myshopify.com vendor/bin/phpunit
Declaring providers explicitly is an upgrade on top of that, not a prerequisite for it:
return HttpVcr\Config::create(
providers: [
'shopify' => new Provider(hosts: ['*.myshopify.com'], requiresEnv: ['SHOPIFY_API_KEY']),
'zendesk-a' => new Provider(hosts: ['account-a.zendesk.com'], requiresEnv: ['ZENDESK_A_API_KEY']),
'zendesk-b' => new Provider(hosts: ['account-b.zendesk.com'], requiresEnv: ['ZENDESK_B_API_KEY']),
],
);
A declared provider buys you three things an implicit one can’t have:
requiresEnv— credential pre-validation scoped to that API (below). This can’t be inferred from a host; it’s the main reason to declare anything at all.- A shorter, more durable name —
@shopifyinstead of@shop.myshopify.com, and it survives the domain changing. - Several hosts as one API —
*.myshopify.comtogether withshopify.dev, refreshed as a unit.
Either way, the payoff is the same: scoping a re-record to one API, including inside a cassette that also talks to others.
An interaction belongs to a provider if its request host matches one of the provider’s patterns. Matching is on the host alone — no scheme, port, or path — case-insensitively, glob-style: *.myshopify.com covers any subdomain, account-a.zendesk.com only that exact host. Nothing about this is written into the cassette; it’s derived at use time from the current configuration, so changing a pattern applies retroactively to everything already recorded and there’s no stored field to drift out of sync.
Resolution for @name, in order: a configured provider with that name, then an exact host seen in the cassettes. Globs are only available to declared providers — *.myshopify.com is a judgement about what counts as one API, not a fact readable from the data. A host claimed by a declared provider stops being addressable by itself, so there’s exactly one spelling for one thing. A name matching neither erases nothing: which hosts a project’s cassettes actually contain is a question only http-vcr providers can answer, since it is the one thing that reads all of them.
Two providers matching the same host is a configuration error too, reported when http-vcr.php loads rather than resolved by declaration order. vendor/bin/http-vcr providers lists which hosts are running on implicit providers — a ready-made shortlist of what’s worth naming.
There’s no imposed structure: http-vcr doesn’t distinguish “platform” from “account” or “instance.” Two Zendesk accounts on separate subdomains are simply two providers — and, unlike a label declared in the test, that separation is enforced by the host itself and can’t be broken by a typo in an attribute.
Pre-validating environment variables
When a request is about to be recorded for real, and a required environment variable is empty, MissingEnvironmentVariableException is thrown before the request goes out:
Cannot record cassette "shopify/get-product": missing env var SHOPIFY_API_KEY
(required by provider "shopify").
— instead of a confusing 401 partway through the test. Two sources are consulted:
- The provider’s
requiresEnv, matched against the host of that specific request. This is where API keys belong. - The cassette’s
requiresEnv(the attribute, or theVcrClientconstructor), for variables that aren’t tied to a host — and the only option at all in a project with nohttp-vcr.php.
Anything with no requiresEnv declared simply isn’t checked; it’s opt-in, not a requirement. When both a provider and the cassette are missing something, one exception names both.
Two things about the timing matter. It fires on the recording branch, not at the start of the test — recording is allowed by default on a developer machine, so validating up front would mean every replaying test there demanded a full set of real credentials it was never going to use. And it’s evaluated per request, not per session, which is what lets VCR_ERASE_TAPE=@shopify refresh the Shopify half of a Shopify→Zendesk cassette while asking only for SHOPIFY_API_KEY.
Selecting VCR tests: no automatic groups
#[UseCassette] does not give your tests a vcr or vcr:shopify group, so --group vcr:shopify won’t select anything unless you add #[Group(...)] yourself. Two reasons, and the second matters more than the first.
It isn’t possible: PHPUnit builds group metadata exclusively from its own attributes, and group filtering happens while the suite is being built — before any Extension API hook runs. A third-party attribute has no way in.
And it wouldn’t be the right tool anyway. A group selects tests, while refreshing a recording is about interactions: a test that talks to two APIs would still need credentials for both and would still re-record traffic that was perfectly fine. What you use instead is split in two:
- Correctness comes from the
VCR_ERASE_TAPEselector — it, not a test filter, decides what gets erased and re-recorded. A run with no filter at all produces the same cassettes. - Convenience comes from
http-vcr tests --provider=…, which prints a ready-made--filterregex so you can skip tests that can’t be affected.
And if you do want groups — to exclude VCR tests from a fast unit-test run, say — add #[Group(...)] to your tests yourself, exactly as you would for any other grouping.
Project configuration
An optional http-vcr.php in the project root — everything has sane defaults without it:
return HttpVcr\Config::create(
cassetteDirectory: __DIR__ . '/tests/Cassettes',
testDirectories: [__DIR__ . '/tests'],
persister: new FilesystemCassettePersister(),
serializer: new JsonCassetteSerializer(),
defaultMatchers: [new MethodMatcher(), new UriMatcher(), new QueryStringMatcher()],
);
This is the single place both the #[UseCassette] attribute and the CLI (stale, providers, scan-secrets) look to find cassettes and test files in the project. Every argument is optional; the full list, including the innerClientFactory the attribute uses to build a real client when it needs to record, is in the Configuration Reference.
Without the file, cassetteDirectory defaults to tests/Cassettes/ relative to the project root (the directory holding composer.json), and the cassette name is a path inside it: shopify/get-product → tests/Cassettes/shopify/get-product.json. That default is the same whether the client comes from #[UseCassette], from new VcrClient(...) in a script, or from the CLI. To put one part of the suite’s cassettes somewhere else, see Cassettes somewhere other than the project root.
http-vcr.php is discovered automatically: the search starts in the current working directory and walks upward, stopping at the first http-vcr.php it finds — or, if none turns up, at the directory containing composer.json (never past it, so a shared CI runner or a monorepo can’t accidentally pick up an unrelated file further up the tree). No file found simply means the defaults apply; it’s optional. For a non-standard layout, bypass discovery entirely with an explicit VcrClient::configure(...) call in the PHPUnit bootstrap, or vendor/bin/http-vcr --config=<path> for the CLI.
A redact option is also available here, for a secret shared across every cassette in the project (say, a company-wide proxy token) — see Redacting Sensitive Data.
Cassettes somewhere other than the project root
In a modular monolith, a module’s cassettes usually belong with the module rather than in one pile under tests/Cassettes/. #[CassetteDirectory] says so, once, on the module’s base test case:
use HttpVcr\Bridge\PHPUnit\CassetteDirectory;
#[CassetteDirectory(__DIR__ . '/Cassettes')]
abstract class BillingTestCase extends TestCase
{
use InteractsWithCassettes;
}
final class ChargeTest extends BillingTestCase
{
#[UseCassette('stripe/charge')] // → modules/Billing/tests/Cassettes/stripe/charge.json
public function testCharge(): void { /* ... */ }
}
__DIR__ works because attribute arguments are constant expressions, so the path is written where you can see it and resolves relative to the file it’s written in. The attribute is looked up on the test class and then up its parent chain, first one found wins — declare it once per module, not once per test class.
Cassette names are unaffected: stripe/charge is still just a path inside whichever directory applies. Nothing is routed by name.
Two limits worth knowing:
- It only covers the PHPUnit path. A hand-built
VcrClientelsewhere takes apersisterargument instead. - The CLI resolves it by parsing, not executing.
stale,testsandscan-secretsread the attribute from the test files’ syntax tree, followingextendsacross every.phpfile undertestDirectories. A base class outside those directories is never parsed, so a#[UseCassette]or#[CassetteDirectory]written on one is invisible to the CLI while still working at run time — keep shared declarations on a base class that lives undertestDirectories. An argument the parser can’t evaluate (staleAfter: self::INTERVAL) is reported as not fully analyzed rather than guessed at.
Environment variables
Four variables — VCR_ALLOW_RECORDING, VCR_ERASE_TAPE, VCR_ENFORCE_STALE_CHECK, VCR_IGNORE_STALE_CASSETTES — control recording and staleness from outside the test code, with locked interactions outranking all of them. They aren’t PHPUnit-specific, so they live in one place: the Environment Variables reference, which also covers precedence, conflict resolution, and how the default for VCR_ALLOW_RECORDING is derived when it isn’t set.
A worked file
The attribute in its usual forms — on a method, on a class, with every parameter, and with a #[CassetteDirectory] base case — is in examples/phpunit-attribute.php.
Shopify’s Official PHP SDK
A real-world case study for the decorator-vs-middleware question from Guzzle: which one does a given Guzzle-based SDK actually need? Verified against the actual source of shopify/shopify-api v6.1.1, not just its docs — worth being precise here, since it’s easy to guess wrong.
What the SDK actually does
shopify/shopify-api builds its own Guzzle client internally rather than accepting one through a constructor: every Shopify\Clients\Rest/Graphql call routes through Context::$HTTP_CLIENT_FACTORY->client(). On its own, that looks like the “SDK owns its own client” case that would normally need the middleware bridge.
But Shopify\Clients\Http::request() — the method every Rest/Graphql call ultimately goes through — calls the client with $client->sendRequest($request). That’s PSR-18’s method, not Guzzle’s native request()/get(). The decorator is enough here — no middleware needed.
Wiring it up
Context::$HTTP_CLIENT_FACTORY is a public static property, and HttpClientFactory is a plain, non-final class with one method to override:
use Psr\Http\Client\ClientInterface;
use Shopify\Clients\HttpClientFactory;
final class VcrHttpClientFactory extends HttpClientFactory
{
public function __construct(private readonly ClientInterface $client) {}
public function client(): ClientInterface
{
return $this->client;
}
}
use HttpVcr\VcrClient;
use Shopify\Context;
use Shopify\Clients\Rest;
Context::initialize(/* apiKey, apiSecretKey, scopes, hostName, sessionStorage, apiVersion, ... */);
// initialize() always sets up a plain HttpClientFactory internally — swap it right after:
Context::$HTTP_CLIENT_FACTORY = new VcrHttpClientFactory(
new VcrClient(new GuzzleHttp\Client(), cassette: 'shopify/get-product'),
);
$client = new Rest($shop, $accessToken);
$response = $client->get(path: 'products'); // now goes through VcrClient::sendRequest()
Context::initialize() doesn’t take a factory parameter — it always constructs a plain HttpClientFactory itself — so the swap happens by assigning the static property directly afterwards, not by passing anything into initialize().
The actual lesson
It’s not “an SDK that builds its own client needs the middleware.” It’s “a client used only through sendRequest() is a decorator case, no matter who constructs it — as long as there’s some seam to swap what gets returned.” Guzzle used through its native API needs the middleware, because that bypasses sendRequest() regardless of who owns the client. Check which method the SDK actually calls before reaching for the middleware — don’t assume from how the client gets built.
VcrClient Reference
Everything a VcrClient can be configured with, in one place. Each parameter is introduced in context elsewhere; this page is the assembled list.
public function __construct(
?ClientInterface $inner,
string $cassette,
RecordMode $mode = RecordMode::RecordIfAbsent,
array $matchers = [],
?StrictMode $strictMode = null,
?DateInterval $staleAfter = null,
array $requiresEnv = [],
bool $recordTransportErrors = false,
bool $decodeCompressedResponse = true,
?int $inlineBodyLimit = null,
bool $repeatablePlayback = false,
bool $locked = false,
?CassetteScopeResolverInterface $scopeResolver = null,
?CassettePersisterInterface $persister = null,
?CassetteSerializerInterface $serializer = null,
?ResponseFactoryInterface $responseFactory = null,
?StreamFactoryInterface $streamFactory = null,
?ClockInterface $clock = null,
?callable $warn = null,
) {}
public function withInner(ClientInterface $inner): self;
| Parameter | Default | What it does |
|---|---|---|
inner | — | The real PSR-18 client to record through. null is only valid when withInner() will supply one; using an inner-less instance on the recording path throws LogicException. |
cassette | — | Cassette name, without extension or scope suffix. A path relative to cassetteDirectory: shopify/get-product → tests/Cassettes/shopify/get-product.json. |
mode | RecordIfAbsent | Record mode. |
matchers | [] → [Method, Uri, QueryString] | Matchers, combined with AND. Empty means the project default from config. |
strictMode | None | AllPlayed / InOrder assertions at cassette close. |
staleAfter | null | Staleness threshold. null means freshness isn’t tracked. |
requiresEnv | [] | Environment variables that must be set — checked on the recording branch, not at construction. See PHPUnit. |
recordTransportErrors | false | Whether to persist transport failures instead of letting them pass through unrecorded. |
decodeCompressedResponse | true | Decompress Content-Encoding: gzip/br/deflate before storing, and strip the header. Turn off only when compression itself is what’s under test. |
inlineBodyLimit | 1048576 (1 MiB) | Bodies above this go to a sidecar file instead of into the cassette. |
repeatablePlayback | false | Cassette-wide default for whether interactions are consumed on replay; overridable per interaction in the data. |
locked | false | Locks the whole cassette from code, on top of the per-interaction data field. |
scopeResolver | NullScopeResolver | Scoping — splits one cassette name across several files by API version. |
persister / serializer | from config | Where and in what format cassettes are stored. |
responseFactory / streamFactory | detected | PSR-17, used to rebuild a replayed response. See below. |
warn | standard error | Where this session’s warnings go: what the secret scan found, and a forced recording a lock made a no-op. The PHPUnit bridge passes its own, so a run prints them together at the end instead of scattered through the output. |
clock | SystemClock | Any PSR-20 Psr\Clock\ClockInterface — the source of “now” for staleAfter; FrozenClock ships with the package for testing that. |
Where a parameter is nullable, null means “whatever the project configured” — the Default column is the value that applies when nothing configured one either.
Every #[UseCassette(...)] argument is one of these parameters under the same name, the attribute adding nothing of its own — requiresEnv really is a core parameter, since only the client knows the moment a real request is about to happen. Providers are project-wide configuration rather than a constructor argument: they describe which APIs the project talks to, so VCR_ERASE_TAPE=@shopify has to mean the same thing for every instance in a run.
PSR-17 factories
Rebuilding a response from a cassette means constructing a ResponseInterface and a StreamInterface, and psr/http-factory ships interfaces only — so the core cannot work without an implementation. Those two, and only those two, are VcrClient constructor parameters: the core never builds a request (it receives them) and never builds a URI (it compares them as strings).
RequestFactoryInterface and UriFactoryInterface are needed by exactly one thing — the Symfony bridge, which builds PSR-7 requests out of Symfony’s request() arguments — so they’re parameters of that constructor instead. Same resolution mechanism, applied lazily: a project that never touches that bridge never needs either one.
Resolution order, first hit wins:
- an explicit constructor argument, then
Config/VcrClient::configure(); - an implementation detected via
class_existsfrom a closed, enumerated list:Nyholm\Psr7\Factory\Psr17Factory— one class implementing all four interfacesGuzzleHttp\Psr7\HttpFactory— likewiseLaminas\Diactoros\ResponseFactory/StreamFactory/RequestFactory/UriFactory— four separate classes, so this provider is resolved interface by interface
- failing that, a
MissingDependencyExceptionnaming which interface is missing. Never a silent failure halfway through the first request.
Configuration is frozen after the first request
VcrClient is a service object configured imperatively after construction — redact() and the other redaction helpers, includeSensitiveHeaders(), beforeRecord()/beforePlayback(), and adding matchers outside the constructor. All of it must happen before the cassette session’s first sendRequest(); afterwards each of those methods throws LogicException.
The point is to make “the hook was registered too late, so the first interaction went to disk unredacted” impossible rather than merely unlikely.
Note “session,” not “instance.” withInner() returns a new object, and the Guzzle middleware calls it on every single request — so a flag stored on the instance would reset constantly and never actually freeze anything under a middleware setup, which is precisely where it’s needed. The flag lives with the rest of the session state (replay-consumption counters, the file lock) in the shared cassette manager, so it survives withInner().
Matchers are unaffected by any of this, since they’re values rather than configurable services: ignoreJsonField() and matchJsonField() return a new matcher rather than mutating one.
Global configuration is frozen too, earlier
VcrClient::configure() sets project-wide defaults and is meant to be called once, before the first VcrClient exists in the process — typically a PHPUnit bootstrap. Calling it afterwards throws LogicException. Without that, the “no global state” promise would be false: two tests in the same process could see different defaults depending on execution order. See Configuration Reference.
Configuration Reference
Project-wide defaults live in one configuration object, reachable two equivalent ways: declaratively through an http-vcr.php file, or imperatively through VcrClient::configure(). Everything in it is optional — http-vcr works with no configuration at all.
http-vcr.php
<?php
use HttpVcr\Config;
use HttpVcr\Matching\MethodMatcher;
use HttpVcr\Matching\UriMatcher;
use HttpVcr\Persistence\FilesystemCassettePersister;
use HttpVcr\Serializer\JsonCassetteSerializer;
return Config::create(
cassetteDirectory: __DIR__ . '/tests/Cassettes',
testDirectories: [__DIR__ . '/tests'],
providers: [
'shopify' => new Provider(hosts: ['*.myshopify.com'], requiresEnv: ['SHOPIFY_API_KEY']),
],
persister: new FilesystemCassettePersister(),
serializer: new JsonCassetteSerializer(),
defaultMatchers: [new MethodMatcher(), new UriMatcher(), new QueryStringMatcher()],
redact: ['<COMPANY_PROXY_TOKEN>' => fn () => $_ENV['COMPANY_PROXY_TOKEN']],
innerClientFactory: fn () => new GuzzleHttp\Client(['timeout' => 30]),
);
| Option | Default | What it does |
|---|---|---|
cassetteDirectory | tests/Cassettes/ under the project root | Where cassettes live. A cassette name is a path inside it. |
testDirectories | tests/ under the project root | Where tests, stale and scan-secrets look for test files to parse — every .php file under them, since a base test case is rarely named *Test.php. Only the CLI uses this. Same root rule as cassetteDirectory — nothing looks for phpunit.xml. |
providers | [] | Named external APIs — host patterns plus the environment variables recording them requires. Optional: VCR_ERASE_TAPE=@name also works against bare hostnames without any configuration. See Providers. |
scanRecordingsForSecrets | true | After a session records anything, check the new interactions for credential-shaped values and warn. Never fails a test. See Redacting Sensitive Data. |
persister | FilesystemCassettePersister | Where cassettes are stored. |
serializer | JsonCassetteSerializer | The on-disk format. |
defaultMatchers | [MethodMatcher, UriMatcher, QueryStringMatcher] | Used by any VcrClient constructed without an explicit matchers list. See why the query string is in there. |
redact | [] | Project-wide redaction rules, as placeholder => value provider. |
innerClientFactory | detected | Builds the real PSR-18 client #[UseCassette] uses when it has to record. See below. |
PSR-17 factories, clock, scopeResolver, strictMode, staleAfter, inlineBodyLimit | see VcrClient Reference | The VcrClient constructor parameters that make sense project-wide, as defaults for every instance. |
innerClientFactory
#[UseCassette] constructs VcrClient on your behalf, so it needs a real client to hand it for the recording path. Resolution mirrors the PSR-17 factories: innerClientFactory if configured; otherwise a class_exists check against GuzzleHttp\Client, Symfony\Component\HttpClient\Psr18Client, Buzz\Client\FileGetContents; otherwise a MissingDependencyException.
A replaying test never touches this client, so a missing one only becomes an error at the moment something actually needs to record — and the message says exactly what’s missing.
Configure it when the real client needs specific settings (a timeout, a proxy, a client certificate) that matter while recording.
How http-vcr.php is found
The search starts in the process’s current working directory and walks upward, stopping at the first http-vcr.php it finds — or, if none turns up, at the directory containing composer.json. It never goes past that boundary, so a shared CI runner or a monorepo can’t accidentally pick up an unrelated config from further up the tree ($HOME, for instance).
Not finding a file is not an error. It means the defaults apply.
The same directory — the one holding composer.json — is what “project root” means for the default cassetteDirectory. One rule covers every entry point: the PHPUnit attribute, a hand-built VcrClient in a script, and the CLI.
To bypass discovery for an unusual layout:
vendor/bin/http-vcr providers --config=path/to/http-vcr.php
or configure imperatively, below.
VcrClient::configure()
The same object, filled in from code instead of a file — for a project that would rather configure in a PHPUnit bootstrap than add a config file:
// phpunit.xml <bootstrap> file
VcrClient::configure(
cassetteDirectory: __DIR__ . '/Cassettes',
serializer: new YamlCassetteSerializer(),
);
These are two entrances to one configuration object, not two mechanisms with separate precedence. If both are used, configure() overrides field by field what was loaded from http-vcr.php — an explicit call in code beats a file picked up automatically in the background, and a field it says nothing about keeps the file’s value. redact is the exception that proves it: rules from both are things the project asked for, so they add up rather than replace each other.
--config is the third way in and the only one that replaces rather than merges — the point of naming a file is that the one discovered automatically was the wrong one.
It must be called once, before the first VcrClient is constructed in the process, and throws LogicException afterwards. That’s what makes “no global state” true rather than aspirational: by the time any test touches a VcrClient, global configuration is already frozen, so there’s nothing to reset between tests and no way for execution order to change what a test sees.
Laravel
The separate mtk3d/laravel-http-vcr package publishes config/http-vcr.php with the same options, defaulting cassetteDirectory to base_path('tests/Cassettes') and testDirectories to base_path('tests'):
php artisan vendor:publish --provider="HttpVcr\Laravel\HttpVcrServiceProvider"
CLI Reference
vendor/bin/http-vcr <command> [options]
In a Laravel app with the separate mtk3d/laravel-http-vcr package, the same six commands are also available as Artisan commands, prefixed with vcr: (stale → vcr:stale, tests → vcr:tests, and so on) — Artisan commands share one flat namespace across the whole framework and every installed package, so they need a prefix to stay collision-free and easy to find in artisan list; a standalone single-purpose binary like vendor/bin/http-vcr doesn’t have that problem, so its commands stay bare.
stale
vendor/bin/http-vcr stale
Lists interactions that have crossed staleAfter — meant as a separate, non-blocking CI step, not a build gate. The per-cassette threshold is read from #[UseCassette(staleAfter: ...)] via the same AST scan tests uses (not by running any tests) — a cassette no test declares a threshold for is simply skipped, and conflicting thresholds declared for the same cassette name are reported rather than silently resolved.
With scoped cassettes, one attribute names a base cassette that exists on disk as several scope files. The declared threshold applies to all of them, and the report names the specific scope file, not just the base name.
providers
vendor/bin/http-vcr providers
Prints each provider configured in http-vcr.php — its host patterns, its requiresEnv, and how many cassettes and interactions belong to it — then the hosts running on implicit providers, i.e. those no configuration has claimed:
shopify *.myshopify.com SHOPIFY_API_KEY 4 cassettes, 11 interactions
zendesk-a account-a.zendesk.com ZENDESK_A_API_KEY 2 cassettes, 3 interactions
Implicit (addressable by host, no requiresEnv):
api.stripe.com 2 cassettes, 5 interactions
A host on an implicit provider works fine — VCR_ERASE_TAPE=@api.stripe.com targets it like any other — it just has no requiresEnv and no shorter name. This section is the shortlist of integrations worth declaring in http-vcr.php.
tests
vendor/bin/http-vcr tests --provider=shopify
vendor/bin/http-vcr tests --provider=shopify --filter-only
Lists the tests that touch a given provider, plus a ready-made regex for PHPUnit’s --filter. --filter-only prints just the regex, for dropping into a shell substitution:
SHOPIFY_API_KEY=xxx VCR_ERASE_TAPE=@shopify \
vendor/bin/phpunit --filter "$(vendor/bin/http-vcr tests --provider=shopify --filter-only)"
This is a speed optimization, never a safety requirement — what gets erased and re-recorded is decided by the VCR_ERASE_TAPE selector, so the same command without any filter produces the same cassettes, just slower.
It answers the question by combining two sources, neither sufficient alone: an AST scan of #[UseCassette] (which test opens which cassette) and the contents of the cassettes (which hosts actually appear in them). The second is what makes it work for a test whose cassette name gives away nothing about it talking to Shopify.
The regex ends a test’s name where PHPUnit does, so the data sets of a test with a data provider (…::testFoo#0) are covered by it, and a test whose name merely starts the same way is not. With no match at all it prints a regex that matches nothing, since an empty --filter would run the whole suite.
One limitation that falls straight out of that: a test whose cassette doesn’t exist yet won’t be listed, because there’s nothing to scan. Record it the first time with an unfiltered run.
Loading the test classes to read their attributes by reflection would mean every one of them needing a correctly configured environment — the exact thing the CLI is supposed to avoid — so it parses the syntax instead. What the scan can resolve statically: scalar literals and arrays of them, enum cases (RecordMode::RecordIfAbsent), and new DateInterval('P7D'), which is what staleAfter almost always looks like since PHP 8.1 allows new in attribute arguments. Anything else (staleAfter: self::INTERVAL, a computed cassette name) is reported as “couldn’t be fully analyzed” rather than guessed at.
scan-secrets
vendor/bin/http-vcr scan-secrets
The full, manual pass of the same scanner that runs automatically after every recording — a heuristic sweep of every cassette’s contents for Bearer tokens, AWS-style AKIA[0-9A-Z]{16} keys, long hex/base64 strings in fields that look like tokens, and Authorization/Cookie/Set-Cookie values that don’t look like placeholders.
Two things this adds over the automatic check: it covers every cassette — the configured directory plus any a test class keeps beside itself with #[CassetteDirectory] — not just what a run happened to record, and it can be made blocking:
vendor/bin/http-vcr scan-secrets --fail-on-findings
Without that flag a finding is reported and the command still exits 0.
The test is what the value looks like, not which redact() rules exist: this command doesn’t run the test suite, so it can’t know about rules registered imperatively in a setUp(). Anything in the <...> convention counts as a placeholder — both the built-in <REDACTED-*> values and your own <API_KEY>. Rules declared in http-vcr.php are read too, since that file can be loaded without running anything.
Sidecar files are scanned as well. They hold the largest payloads in the project and go through redaction like any inline body, so leaving them out would be the exact gap this command exists to close. Sidecars whose content isn’t text (an image, an archive) are skipped, to avoid false positives from arbitrary byte sequences.
lock / unlock
vendor/bin/http-vcr lock shopify/checkout --interaction=2
vendor/bin/http-vcr unlock shopify/checkout --interaction=2
Sets or clears "locked": true on a specific interaction. Without --interaction, applies to every interaction in the file at once. With scoped cassettes, a bare name covers every scope file for that cassette; --scope=2024-01 narrows it to one. See Locked Interactions.
Exit codes
A finding is not a failure. stale reports interactions past their threshold and still exits 0 — crossing a threshold is a fact about the clock, and the same commit run an hour later would answer differently — and scan-secrets exits 0 unless asked for --fail-on-findings. What does exit non-zero, in every command that reads cassettes, is a cassette that cannot be parsed: that is a defect in the file rather than a verdict about its contents. tests also exits non-zero when --provider names something that is neither a configured provider nor a host in any cassette, and lists both sets.
Running from inside a consuming project
Composer links the binary into the consuming project’s vendor/bin, and the script resolves the host project’s vendor/autoload.php and http-vcr.php, not its own package directory — so it works the same whether it’s run from a project that depends on http-vcr or from http-vcr’s own test suite. For an unusual layout, point it at a config explicitly:
vendor/bin/http-vcr providers --config=path/to/http-vcr.php
The CLI is built on symfony/console and reads attributes with nikic/php-parser; both are regular dependencies of the package rather than optional extras, so these commands work immediately after composer require --dev mtk3d/http-vcr. Neither reaches a production autoloader, since the package itself is a dev dependency. The record/replay core doesn’t touch either one.
Environment Variables
Four variables control http-vcr from outside the code. Above all of them sits something that isn’t a variable at all — a locked interaction — so the precedence list starts there.
| Level | Effect | Default |
|---|---|---|
locked: true (data field or #[UseCassette(locked: true)]) | Overrides everything below: a locked interaction never makes a real request, whatever any variable says. Unlocking is manual only. See Locked Interactions | false |
VCR_ALLOW_RECORDING | 0 blocks the recording branch of any record mode — a missing cassette or scope fails instead of recording — even if VCR_ERASE_TAPE asks for a re-record | unset → 0 when CI is detected, 1 otherwise (see below) |
VCR_ERASE_TAPE | A comma-separated list of [cassette][@provider] selectors — forces a fresh recording of whatever they select, regardless of the mode declared in code. A bare 1/0 is not a valid value. Full syntax below | unset — nothing is erased |
VCR_IGNORE_STALE_CASSETTES | 1 treats every cassette as fresh, whatever recordedAt says — overrides VCR_ENFORCE_STALE_CHECK | 0 |
VCR_ENFORCE_STALE_CHECK | 1 makes a stale cassette fail the test instead of only being reported | 0 |
The four variables fall on two independent axes, and only the pairs within an axis can actually contradict each other:
- Recording:
VCR_ALLOW_RECORDINGandVCR_ERASE_TAPE. - Staleness:
VCR_ENFORCE_STALE_CHECKandVCR_IGNORE_STALE_CASSETTES.
Resolving conflicts
VCR_ALLOW_RECORDING=0 beats VCR_ERASE_TAPE=shopify/get-product: recording stays blocked, and http-vcr says so out loud (recording disabled by VCR_ALLOW_RECORDING=0, ignoring VCR_ERASE_TAPE) rather than letting one variable silently win over the other. The CI safety net outranks a manual override on purpose — a stray VCR_ERASE_TAPE left in a pipeline config shouldn’t be able to open the door to the real API. The failure is a RecordingNotAllowedException, which names whether the 0 was set explicitly or inferred, and from which variable.
A locked interaction plus VCR_ERASE_TAPE plus VCR_ALLOW_RECORDING=1 — that is, recording fully and deliberately enabled for that cassette — leaves the locked interaction untouched, with no error. That’s not a conflict to report; it’s the lock doing its one job.
VCR_ERASE_TAPE selectors
A selector has two independently optional halves, separated by @: which cassettes, and which interactions inside them. Several selectors are separated by commas.
| Selector | Cassettes | Interactions in them |
|---|---|---|
shopify/get-product | that one | all |
shopify/get-product,shopify/list-products | those two | all |
all | every cassette the run opens | all |
@shopify | every cassette the run opens | only those belonging to provider shopify |
@shop.myshopify.com | every cassette the run opens | only those sent to that host — every host is implicitly its own provider, so this needs no configuration |
sync/order-flow@shopify | that one | only shopify’s |
all@shopify | the explicit spelling of @shopify | — |
A cassette records one test’s traffic, which is why the cassette half of a selector names a scenario as readily as a service (What one cassette covers). The @provider half is what makes a test that talks to two APIs refreshable one API at a time: the interactions that don’t belong to the named provider survive the truncation and replay from the cassette as usual, so the run only needs credentials for the API being refreshed. A name is resolved first against configured providers, then against the host of each interaction as the cassette is opened; a name matching neither erases nothing. http-vcr providers is what lists both sets, since checking a name against every cassette in the project means reading every cassette in the project.
@ can’t be confused with part of a cassette name, since names are sanitized to [A-Za-z0-9_.-] and /.
VCR_ERASE_TAPE and scoped cassettes
The cassette half matches on the base name, not on the file name with its scope suffix. VCR_ERASE_TAPE=shopify/get-product therefore catches the session whether the file actually opened is shopify/get-product.2024-01.json or shopify/get-product.2024-04.json — you name the cassette the test declares, not the file that happens to be on disk for the current API version.
CI detection
VCR_ALLOW_RECORDING has three states, not two:
| Value | Result |
|---|---|
1 or 0 | exactly that — an explicit value always wins |
| unset, CI detected | recording blocked |
| unset, no CI signal | recording allowed |
Detection is a closed, enumerated list, so it can be predicted without reading the source. CI is considered detected when any of these is set to a non-empty value other than 0/false:
CICONTINUOUS_INTEGRATIONBUILD_NUMBERJENKINS_URLTEAMCITY_VERSION
The first two cover GitHub Actions, GitLab CI, CircleCI, Travis, Buildkite, Drone and most hosted runners, all of which set CI=true on their own. The last three cover Jenkins and TeamCity, which don’t.
Detection is a default, not a rule. It exists so the common case needs no setup — and either way it can be wrong without much cost:
- False positive (a local machine that sets
CIfor its own reasons) → recording is blocked, the safe direction. The error names the variable that triggered detection. - False negative (a runner that sets none of them) → recording is allowed, same as locally. Setting
VCR_ALLOW_RECORDING=0in the pipeline is one line, and is worth doing regardless of detection.
In a Laravel app, the Laravel bridge package adds a second condition to the same default: recording is allowed only when the environment is local/testing and no CI signal was detected. It narrows the default, never widens it — an environment check replacing CI detection would allow recording on CI, where tests run with APP_ENV=testing. An explicit variable still wins.
Recipes
Recording is allowed by default on a developer machine, so the local recipes below don’t set VCR_ALLOW_RECORDING=1 — it’s only worth spelling out when something in the shell sets CI, or to make the intent explicit in a script someone else will read.
# re-record exactly one cassette from scratch — safe without a test filter
VCR_ERASE_TAPE=shopify/get-product vendor/bin/phpunit
# refresh one API everywhere it appears, locked interactions excepted —
# including inside cassettes that also talk to other APIs
SHOPIFY_API_KEY=xxx VCR_ERASE_TAPE=@shopify vendor/bin/phpunit
# same, but skip the tests that can't be affected (speed only, not safety)
SHOPIFY_API_KEY=xxx VCR_ERASE_TAPE=@shopify \
vendor/bin/phpunit --filter "$(vendor/bin/http-vcr tests --provider=shopify --filter-only)"
# CI: never touch the network, whatever any test declares
VCR_ALLOW_RECORDING=0 vendor/bin/phpunit
# let a hotfix through a pipeline that enforces staleness
VCR_IGNORE_STALE_CASSETTES=1 vendor/bin/phpunit
Cassette Format Reference
Top-level fields
| Field | Type | Meaning |
|---|---|---|
schemaVersion | int | Format version, starting at 1. An unknown or newer version throws rather than attempting to parse an unrecognized shape; an older, still-supported version goes through an incremental, per-field upgrade path. |
interactions | array | The recorded interactions, in recording order — order is significant for StrictMode::InOrder. |
Per-interaction fields
| Field | Type | Meaning |
|---|---|---|
request.method | string | HTTP method. |
request.uri | string | Full URI. |
request.headers | array<string, string[]> | Header names as recorded, values as a list — supports repeated headers. |
request.body | string | Request body, or "". See the body-encoding fields below. |
response.status | int | HTTP status code. |
response.headers | array<string, string[]> | Same shape as request headers. |
response.body | string | Response body. The whole response object is absent when outcome is "error" — there was no response. |
outcome | "success" | "error" | "error" for a recorded transport failure; "success" otherwise. |
errorCategory | "network" | "request" (absent for "success") | Which PSR-18 exception interface the original failure implemented — NetworkExceptionInterface or RequestExceptionInterface. Determines whether replay throws VcrNetworkException or VcrRequestException. |
errorMessage | string (absent for "success") | The original exception’s message, replayed into http-vcr’s own exception. Subject to redaction like any other stored field — client exception messages tend to quote the full request URL. |
errorClass | string (absent for "success") | The original exception’s class name (e.g. GuzzleHttp\Exception\ConnectException) — diagnostic metadata only, never used to reconstruct that class on replay. See Transport Errors. |
recordedAt | string (ISO 8601) | When this interaction was recorded — used by staleAfter. |
locked | bool | See Locked Interactions. |
repeatablePlayback | bool | When true, this interaction isn’t “consumed” on replay — see Record Modes. |
Body encoding fields
These three live inside request and response, not at the interaction level — one interaction has two bodies, and they’re routinely of different kinds (a small text request alongside a large binary response is just “downloading a file”). So the canonical paths are request.bodyEncoding, response.bodyRef, and so on.
| Field | Type | Meaning |
|---|---|---|
bodyEncoding | "base64" (absent for text) | Present when an inline body is binary. Detected from Content-Type: text/*, application/json, application/*+json and application/x-www-form-urlencoded are treated as text, everything else as binary. Never present together with bodyRef. |
bodyRef | string (absent for inline bodies) | The 16-character content hash identifying a sidecar file, for bodies over the inline size threshold (1 MiB by default). Mutually exclusive with bodyEncoding — a sidecar holds raw bytes, so there’s nothing for base64 to solve. When present, body is absent. |
bodySha256 | string (absent for inline bodies) | Full SHA-256 of the sidecar body, checked against the sidecar’s actual content on read — a mismatch throws CassetteIntegrityException instead of silently returning the wrong bytes. |
bodyRef holds the hash alone, not the sidecar’s full filename: the filename is derived from the hash plus the name of the cassette file currently open. Storing the full name would duplicate, inside the data, the name of the file containing that data — so renaming a cassette or changing its scope would invalidate every reference in it, even though the sidecars get renamed alongside and still match.
Sidecar files
Bodies over the inline threshold are written next to the cassette, named after a hash of their content:
{cassette}.{sha256(body)[0:16]}.bin
Content-hash naming — rather than positional, e.g. {cassette}.0.bin — means reordering interactions in the JSON file by hand never breaks a bodyRef, and identical bodies across interactions are automatically deduplicated to a single sidecar file.
{cassette} here is the name of the cassette file actually in use, scope suffix included and format extension excluded: a sidecar of get-product.2024-01.json is get-product.2024-01.{hash}.bin. Without the scope, two scopes of one base cassette would share a sidecar namespace, and deleting one scope could take files the other still needs.
Sidecars are written through the same persister as the cassette itself, so the same name sanitization and locking rules apply. CassettePersisterInterface::list() only returns entries matching the serializer’s own extension, so sidecars and lock files never show up there — otherwise commands like stale would try to deserialize raw bytes as a cassette.
Sidecars that nothing references any more — after a forced re-record, after an interaction is deleted by hand, after a body shrinks below the threshold — are removed when the cassette is next written. Deduplication makes that safe: a file only disappears once its last reference does.
Lock files
While a session is recording, http-vcr holds an exclusive lock so two parallel test processes (paratest and friends) can’t interleave their writes into one cassette. The lock lives on a separate {cassette}.cassette-lock file, not on the cassette itself — cassettes are replaced via an atomic rename(), which swaps the file’s inode, and a lock held on an inode that’s no longer at that path stops excluding anything. The lock file is empty and created on demand, in a .http-vcr/ directory inside the cassette directory:
tests/Cassettes/
├── .http-vcr/
│ ├── .gitignore (holds `*`)
│ └── shopify/get-product.cassette-lock
└── shopify/get-product.json
That directory carries its own .gitignore, so lock files stay out of version control with nothing to configure and nothing added to the project’s own ignore rules. They stay on disk once a session ends, which is why they live somewhere out of the way rather than beside the recordings: deleting one would reopen the race the separate file exists to avoid, since a process waiting on the lock would acquire it on an inode no longer at that path while the next process created a fresh one.
It sits next to the cassette rather than in the system temp directory, which matters more than it looks: /tmp isn’t shared across a container boundary. A suite running inside Docker and another run started on the host see the same cassette directory through a bind mount but two different /tmps — they’d take locks on two separate files and never exclude each other. The lock has to live where the resource lives, because that path is the only one every process allowed to write the cassette can agree on.
Replaying takes no lock at all. The atomic rename already guarantees a reader sees either the whole old file or the whole new one, never a half-written mix — which also means a normal CI run needs no write access to the cassette directory.
RecordIfAbsent decides between recording and replaying based on whether the file exists, and that check happens under the lock, not before it: two parallel processes starting the same not-yet-recorded test would otherwise both see nothing, both take the recording branch, and the second would append a duplicate of what the first had just recorded. So the sequence is take the lock, re-check, and — if the cassette appeared in the meantime — release it and carry on as an ordinary replaying session.
Write pipeline order
Three things operate on the same body content and have to run in a fixed order, or redaction could receive still-compressed bytes it can’t process, or a secret could reach a sidecar file before it’s been redacted:
- decompression (
Content-Encodingstripped, body is plain text) beforeRecordhooks, including redaction — always on the full, inline content- the
inlineBodyLimitcheck and sidecar write, on the already-redacted content - serialization to disk (locked and written atomically)
redactJsonField/redactFormField behave identically whether an interaction ends up inline or in a sidecar — the sidecar decision is made on content that’s already been redacted.
Exceptions
Everything http-vcr throws implements HttpVcr\Exception\VcrException, so a test that only wants to know “the VCR layer refused” can catch that one type.
VcrException
├── NoMatchingInteractionException
│ └── CassetteNotFoundException
├── StrictModeViolationException
├── StaleCassetteException
├── MissingEnvironmentVariableException
├── MissingDependencyException
├── RecordingNotAllowedException
├── CassetteFormatException
│ └── CassetteIntegrityException
├── VcrNetworkException (also PSR-18 NetworkExceptionInterface)
└── VcrRequestException (also PSR-18 RequestExceptionInterface)
| Exception | Thrown when |
|---|---|
NoMatchingInteractionException | The cassette exists, but no still-unconsumed interaction matches the incoming request. See Matching Requests for the message format. |
CassetteNotFoundException | There’s no cassette file at all — either never recorded, or the scope changed and no file exists for the new one. In the scope case the message also lists the scopes that do exist. |
StrictModeViolationException | AllPlayed or InOrder wasn’t satisfied when the cassette closed. |
StaleCassetteException | A cassette crossed its staleAfter threshold and VCR_ENFORCE_STALE_CHECK asked for that to be an error. Lists the interactions that outlived it, with the date each went stale. |
MissingEnvironmentVariableException | A variable listed in requiresEnv is empty at the moment a real request was about to be made. |
MissingDependencyException | No PSR-17 factory implementation could be found to rebuild responses with, or #[UseCassette] found no HTTP client to record through. Names the specific interface or class it looked for. Rare in practice — whichever HTTP client you use already brings a factory along. |
RecordingNotAllowedException | Something needed recording, but VCR_ALLOW_RECORDING=0 blocked it. The message says whether that 0 was set explicitly or inferred from CI detection, and from which variable. |
CassetteFormatException | A cassette’s schemaVersion is unknown or newer than this installation understands, or its contents can’t be deserialized. |
CassetteIntegrityException | A sidecar file doesn’t hash to the bodySha256 recorded for it — hand-edited, truncated, or partially restored from a backup. |
VcrNetworkException / VcrRequestException | Replaying a recorded transport failure. |
Which one you get when nothing came back
Three of these all end with “the test didn’t get a response,” which makes them easy to confuse. The rule is that the exception names why no recording happened, not merely that there was no response:
- No cassette file (or none for the computed scope), with the mode not allowing a recording —
PlaybackOnly→CassetteNotFoundException - The file is there, but nothing in it matches, or the matching interactions were already consumed →
NoMatchingInteractionException, in any mode.RecordIfAbsentagainst an existing cassette lands here, not onCassetteNotFound - A recording would have happened, but
VCR_ALLOW_RECORDING=0→RecordingNotAllowedException, whether what was missing was the file or just a match. This one takes precedence in the message over the two above, because it’s the actual cause: the identical run with recording allowed would have succeeded
Catching by PSR-18 contract
VcrNetworkException and VcrRequestException implement two things at once: their PSR-18 counterpart (NetworkExceptionInterface / RequestExceptionInterface, both extending ClientExceptionInterface) and VcrException.
That means application error handling written against the PSR-18 contract — as PSR-18-aware code should be — behaves under replay exactly as it does against a genuine network failure. And a test that catches VcrException broadly still catches these two, without needing a special case.
What http-vcr deliberately does not do is reconstruct the original client’s exception class (GuzzleHttp\Exception\ConnectException and the like). PSR-18 standardizes the interfaces, not the constructors, so there’s no safe general way to rebuild an arbitrary library’s exception from stored data. The original class name is kept in the cassette as diagnostic metadata — see Transport Errors.
LogicException, not VcrException
Misuse of the API throws a plain LogicException rather than a VcrException, because it’s a bug in the calling code and not something a test should ever catch:
- registering a hook, matcher, or redaction rule after the cassette session’s first request (why)
- calling
VcrClient::configure()after the firstVcrClientexists in the process - declaring two providers whose host patterns can both match the same host
- using a
VcrClientbuilt withinner: nullon a path that needs a real request, withoutwithInner() - returning
nullfrom abeforePlaybackhook
Roadmap
Ideas worth keeping track of, but not scheduled into any milestone yet:
- Match diagnostics on demand (
VCR_DEBUG_MATCHING=1) — a full match trace for every interaction × matcher, not just when nothing matches at all. diff— a semantic diff between two versions of the same cassette (JSON Pointer level, not line-by-line), for reviewing what an updated recording actually changed.update— re-record a cassette, show a semantic diff of what changed, and ask for confirmation instead of blindly overwriting.- Schema drift detection — flag when a re-recorded interaction’s JSON shape changed (fields added or removed), independent of
staleAfter, which only measures elapsed time. - Subset JSON matching — alongside the current strict
BodyJsonMatcher, a mode where the recorded body only needs to be a subset of the incoming one, or vice versa. MultipartMatcher— structural matching formultipart/form-data, since a random boundary makes raw body matching useless.CookieMatcher/SetCookieMatcher— structural parsing ofCookie/Set-Cookieinstead of treating them as opaque header strings.- Pattern-based header matching — a header equivalent of
matchJsonField, for headers likeIdempotency-Keyortraceparentthat shouldn’t be matched exactly or ignored entirely. - Configurable query string matching —
ordered/unordered/ignore(['page'])per parameter, instead of one fixed comparison strategy. - Request assertions (
expectRequest()) — a cassette that’s both a fixture and a verification that the code under test actually sent the expected data, not just that it got a response back. - Deduplication via repeat count — one entry plus
repeat: Ninstead of N identical interactions for N identical calls. - Parallel recording to separate files, merged after the fact — an alternative to the session-wide lock, for teams that deliberately want to record in parallel.
- Provider-scoped redaction — redaction rules attached to a
Provider(applied to interactions whose host matches) instead of only the flat, project-wideredactinConfig. Coherent since providers became a core concept, but deliberately deferred: the common case is already covered by the defaultAuthorizationredaction, and the rest has a one-line workaround in a base test case. - File-level cassette metadata (
generator,source.test,source.provider) — to support tooling likelist/infowithout parsing every interaction.
None of this is committed to a milestone — it’s tracked here so it isn’t lost, not because it’s coming soon.
System Context
The C4 model describes a system at four zoom levels. This page is level 1: what sits around http-vcr and who talks to whom. Containers opens the test process up, and Components opens the library up.
One thing to keep in mind at every level: http-vcr is a library, not a service. Nothing
here is a running process you deploy. The boxes are the pieces of a test run, and the
“deployment” is composer require --dev.
graph TB
dev["<b>Developer</b><br/><span>Person</span><br/><br/>Writes tests, records<br/>cassettes on their machine"]
ci["<b>CI Pipeline</b><br/><span>Person / automation</span><br/><br/>Runs the suite with no<br/>network and no credentials"]
subgraph scope[" "]
suite["<b>Test Suite</b><br/><span>Software System — PHP</span><br/><br/>The project's tests, with http-vcr<br/>decorating the HTTP client the<br/>code under test already uses"]
end
api["<b>Third-party HTTP API</b><br/><span>External System</span><br/><br/>Shopify, Stripe, Zendesk…<br/>Reached on the recording run only"]
repo["<b>Version Control</b><br/><span>External System</span><br/><br/>Cassettes are committed<br/>next to the tests"]
dev -->|"runs the suite,<br/>records the first time"| suite
ci -->|"runs the suite,<br/>replay only"| suite
suite -->|"HTTPS — first run only,<br/>with real credentials"| api
suite -->|"reads and writes<br/>cassette files"| repo
dev -->|"reviews cassette diffs<br/>in code review"| repo
classDef person fill:#08427b,stroke:#052e56,color:#ffffff
classDef system fill:#1168bd,stroke:#0b4884,color:#ffffff
classDef external fill:#999999,stroke:#6b6b6b,color:#ffffff
classDef boundary fill:none,stroke:#444444,stroke-dasharray:5 5,color:#888888
class dev,ci person
class suite system
class api,repo external
class scope boundary
What the picture is claiming
The API is reached exactly once per cassette. The arrow from the suite to the third-party API is dashed in spirit: it carries traffic on the run that records, and nothing on every run after that. That is the whole point of the library, and it is also why the arrow from CI to the API does not exist — recording is refused on CI so that a missing cassette fails loudly instead of quietly spending real API quota from a build agent.
Cassettes are source, not cache. They are committed, reviewed, and diffed like any other fixture. This is why the format is JSON by default and why secrets are redacted before the file is written rather than after — a cassette that reached disk with a live token in it is already a credential leak in the repository’s history.
There is no process-wide state. http-vcr does not install a stream wrapper, patch
curl, or register anything global; it
decorates one PSR-18 client instance. Two
VcrClient objects in one test run do not interfere, and code that was never handed a
decorated client keeps reaching the network exactly as before.
Actors
| Actor | What they do | What they must have |
|---|---|---|
| Developer | Runs the suite locally, records the cassette the first time a test needs one, commits it | Real API credentials in the environment, network access |
| CI pipeline | Runs the same suite in replay | Neither. If a cassette is missing, the run fails rather than reaching out |
The split between those two rows is a rule the library enforces, not a convention it suggests. See Record Modes for how the decision is made and how to override it in either direction.
Containers
Level 2 opens up the test process. “Container” in C4 normally means a deployable unit — a service, a database, an app. For a library the useful reading is a thing with its own lifecycle: the PHP process the tests run in, the files on disk that outlive it, and the CLI you run separately.
graph TB
dev["<b>Developer</b><br/><span>Person</span>"]
subgraph proc["Test process — one PHPUnit run"]
sut["<b>Code Under Test</b><br/><span>PHP</span><br/><br/>Your service class, an SDK,<br/>anything that sends requests"]
vcr["<b>VcrClient</b><br/><span>PHP — this library</span><br/><br/>PSR-18 decorator. Records on<br/>a miss, replays on a hit"]
client["<b>Real HTTP Client</b><br/><span>Guzzle · Symfony · php-http</span><br/><br/>Any PSR-18 implementation.<br/>Only called while recording"]
bridge["<b>PHPUnit Bridge</b><br/><span>PHP</span><br/><br/>#[UseCassette] opens and closes<br/>the session around each test"]
end
subgraph disk["Working tree"]
cass[("<b>Cassette Files</b><br/><span>JSON · YAML</span><br/><br/>tests/Cassettes/**.json<br/>Committed to the repository")]
side[("<b>Sidecar Bodies</b><br/><span>Binary files</span><br/><br/>Bodies past 1 MiB, stored<br/>beside the cassette")]
conf["<b>http-vcr.php</b><br/><span>PHP config file</span><br/><br/>Project-wide defaults,<br/>providers, redaction rules"]
end
cli["<b>http-vcr CLI</b><br/><span>Symfony Console</span><br/><br/>stale · tests · providers<br/>scan-secrets · lock · unlock"]
api["<b>Third-party HTTP API</b><br/><span>External System</span>"]
dev -->|runs| proc
dev -->|runs| cli
bridge -->|"opens the session,<br/>closes it after the test"| vcr
sut -->|"sendRequest()<br/><span>PSR-18</span>"| vcr
vcr -->|"only on a cassette miss<br/>with recording allowed"| client
client -->|HTTPS| api
vcr -->|"read · append · lock"| cass
vcr -->|"large bodies by reference"| side
vcr -->|"read once, frozen<br/>at first request"| conf
cli -->|"scans #[UseCassette],<br/>reads and edits"| cass
cli -->|reads| conf
classDef person fill:#08427b,stroke:#052e56,color:#ffffff
classDef container fill:#438dd5,stroke:#2e6295,color:#ffffff
classDef store fill:#438dd5,stroke:#2e6295,color:#ffffff
classDef external fill:#999999,stroke:#6b6b6b,color:#ffffff
classDef boundary fill:none,stroke:#444444,stroke-dasharray:5 5,color:#888888
class dev person
class sut,vcr,client,bridge,cli container
class cass,side,conf store
class api external
class proc,disk boundary
The one arrow that matters
VcrClient → Real HTTP Client is the only path to the network, and it is conditional. On a
cassette hit it is never taken; the response is rebuilt from the recorded snapshot through
a PSR-17 factory. That is what makes the suite deterministic — not a mock the test has to
set up, but a decorator that answers from a file.
Because the decorator sits above the real client, everything the client does — retries, middleware, connection pooling — is on the far side of the recording. What lands in the cassette is what the client returned, not what the wire carried.
Lifecycles
The three boxes have genuinely different lifetimes, and most of the library’s design follows from that:
| Container | Lives for | Consequence |
|---|---|---|
VcrClient | One instance, possibly many per test | Cheap to construct; holds no cross-test state |
| Cassette session | One test | Owns the lock, the consumption counters, the strict-mode verdict |
| Cassette file | The repository’s lifetime | Must stay diffable and free of secrets |
The middle row is the subtle one. State that looks like it belongs on the client actually
belongs to the session, because the Guzzle bridge
produces a fresh VcrClient per request
and hooks registered on one of them have to apply to all of them. See
ADR-0006 for why configuration
freezes at the session boundary rather than the object boundary.
The CLI is a separate reader
bin/http-vcr runs outside the test process entirely. It never constructs a VcrClient;
it reads cassettes through the same serializer and finds #[UseCassette] declarations by
parsing test files with nikic/php-parser rather than by loading them. Static analysis
rather than reflection, so a test file that would fatal on load can still be inventoried.
See the CLI Reference for the commands themselves.
Components
Level 3 opens up the library itself. Every box here is a class or a small cluster of them
in src/.
graph TB
sut["<b>Code Under Test</b><br/><span>External</span>"]
inner["<b>Real PSR-18 Client</b><br/><span>External</span>"]
subgraph core["http-vcr"]
vcr["<b>VcrClient</b><br/><span>src/VcrClient.php</span><br/><br/>PSR-18 entry point. Snapshots the<br/>request, rebuilds the response,<br/>decompresses, base64-encodes"]
session["<b>CassetteSession</b><br/><span>src/Cassette/</span><br/><br/>The cassette as the test names it.<br/>Routes to a file per scope; owns<br/>hooks and the started flag"]
manager["<b>CassetteManager</b><br/><span>src/Cassette/</span><br/><br/>One cassette file. Consumption<br/>counters, the lock, record<br/>permission, strict-mode verdict"]
matcher["<b>Matchers</b><br/><span>src/Matching/</span><br/><br/>CompositeMatcher over Method,<br/>Uri, QueryString by default.<br/>Explains its own mismatches"]
hooks["<b>HookRegistry</b><br/><span>src/Hook/</span><br/><br/>beforeRecord / beforePlayback<br/>in registration order"]
redact["<b>RedactionHooks</b><br/><span>src/Hook/</span><br/><br/>Always the first hook in both<br/>directions. Header, JSON field,<br/>query param, form field, value"]
ser["<b>Serializer</b><br/><span>src/Serializer/</span><br/><br/>ArrayCassetteSerializer holds the<br/>schema; JSON and YAML are two<br/>spellings of it"]
pers["<b>Persister</b><br/><span>src/Persistence/</span><br/><br/>Filesystem store, atomic rename,<br/>session lock, sidecar bodies"]
env["<b>Environment</b><br/><span>src/Environment.php</span><br/><br/>CI detection, VCR_* variables,<br/>provider credential checks"]
conf["<b>Config</b><br/><span>src/Config.php</span><br/><br/>http-vcr.php merged field by<br/>field. Frozen on first use"]
scope["<b>Scope Resolver</b><br/><span>src/Scope/</span><br/><br/>Turns a request into a scope,<br/>which becomes part of the filename"]
psr17["<b>Psr17FactoryResolver</b><br/><span>src/Psr17FactoryResolver.php</span><br/><br/>Finds a response/stream factory<br/>in whatever the project installed"]
scan["<b>SecretScanner</b><br/><span>src/SecretScanner.php</span><br/><br/>Warns after a recording session<br/>if a value looks like a credential"]
end
store[("<b>Cassette Files</b>")]
sut -->|sendRequest| vcr
vcr -->|begin · for · close| session
session -->|"one per scope"| manager
session -->|routes with| scope
session -->|owns| hooks
hooks -->|"registered first"| redact
manager -->|"asks for a match"| matcher
manager -->|"read · write"| ser
ser -->|bytes| pers
pers -->|files| store
manager -->|"may this run record?"| env
vcr -->|"defaults from"| conf
vcr -->|"rebuild response"| psr17
vcr -->|"real request on a miss"| inner
manager -->|"warns through"| scan
classDef comp fill:#438dd5,stroke:#2e6295,color:#ffffff
classDef external fill:#999999,stroke:#6b6b6b,color:#ffffff
classDef store fill:#438dd5,stroke:#2e6295,color:#ffffff
classDef boundary fill:none,stroke:#444444,stroke-dasharray:5 5,color:#888888
class vcr,session,manager,matcher,hooks,redact,ser,pers,env,conf,scope,psr17,scan comp
class sut,inner external
class store store
class core boundary
Why the session and the manager are two classes
CassetteSession is the name the test used. CassetteManager is one file. Without
scoping those are the same thing and the session is a thin front. With
a scope resolver one name spans a file per scope, and the two
responsibilities come apart: the hook pipeline and the redaction rules belong to the test,
while the lock, the consumption counters and the strict-mode verdict belong to each file
separately. Pooling the latter across scopes would hide which file a leftover interaction
was actually in — ADR-0010.
A request, end to end
The dynamic view. This is one sendRequest() call, on a cassette that already has a
matching interaction:
sequenceDiagram
participant SUT as Code Under Test
participant VCR as VcrClient
participant S as CassetteSession
participant M as CassetteManager
participant H as HookRegistry
participant P as Persister
SUT->>VCR: sendRequest(request)
VCR->>S: begin()
Note over S: config freezes here
VCR->>VCR: snapshot(request)
Note over VCR: body buffered, headers<br/>normalised, RecordedRequest built
VCR->>S: for(request)
S->>M: resolve scope → this file
M->>P: read cassette (first request only)
P-->>M: bytes
VCR->>M: play(incoming)
M->>M: match against unconsumed interactions
M->>H: beforePlayback(interaction)
Note over H: redaction restores<br/>two-way placeholders
H-->>M: interaction
M-->>VCR: interaction
VCR->>VCR: rebuild response via PSR-17
VCR-->>SUT: ResponseInterface
On a miss the tail differs: CassetteManager reports whether recording is allowed, and
VcrClient either sends through the real client and appends the result — passing it through
beforeRecord, where redaction strips secrets before anything reaches the serializer — or
throws. Which exception depends on why:
RecordingNotAllowedException when the environment forbade it,
CassetteNotFoundException when there is no file, and
NoMatchingInteractionException when the file exists but nothing in it matched. The last
one carries the mismatch explanations the matchers produced, so the message says which
field differed, not just that nothing matched.
Extension points
Four interfaces are meant to be implemented from outside:
| Interface | For |
|---|---|
Matching\RequestMatcherInterface | Deciding whether a recorded request is this request. Add ExplainsMismatch to get your reason into the failure message |
Persistence\CassettePersisterInterface | Storing cassettes somewhere other than the filesystem. Add SupportsSessionLocking if the store can hold an exclusive lock |
Serializer\CassetteSerializerInterface | A different on-disk spelling of the same schema |
Scope\CassetteScopeResolverInterface | Splitting one cassette name across several files |
The two “add this second interface” rows are deliberate. Both capabilities are things a store or a matcher may genuinely be unable to provide, and requiring them on the main interface would make perfectly good implementations impossible — ADR-0005.
Edge Cases
The awkward inputs a record/replay library meets in practice, and what http-vcr does with
each. Every behaviour on this page has a test in tests/Integration/ named after it — if a
row here and the code ever disagree, the test is the one telling the truth.
Streams
PSR-7 bodies are streams, and streams are not obliged to rewind. A request body built from a
socket, a pipe, or php://input can be read exactly once.
| Situation | Behaviour |
|---|---|
| Request body cannot be rewound | Buffered once at the snapshot boundary. Both the recording and the real client get a readable stream over the buffered bytes, so the request still reaches the network intact |
| Response body cannot be rewound | Buffered the same way. The code under test gets a fresh readable stream, not the drained original |
| Body is seekable | Handed back as the same stream, rewound — no needless copy |
This is the practical consequence of ADR-0004: everything becomes a string at the boundary, so nothing downstream can be surprised by a consumed handle.
Bodies
| Situation | Behaviour |
|---|---|
| Binary response (PDF, image, gzip under test) | Stored base64-encoded, flagged in bodyEncoding |
| Text | Left readable in the file, so diffs stay reviewable |
| Claims to be text but is not valid UTF-8 | Treated as binary. The decision is made on the content, not on Content-Type |
| Binary request body | Encoded too — the rule is not response-only |
| Empty body | Never encoded. An empty string stays an empty string |
Large bodies and sidecar files
Past inlineBodyLimit (default 1 MiB) the body moves to a file of its own — see
ADR-0013.
| Situation | Behaviour |
|---|---|
| Body over the threshold | Written beside the cassette; the cassette keeps a reference |
| Replay of a sidecar body | Byte-for-byte identical to what was recorded |
| Body under the threshold | Stays inline in the cassette |
| Two interactions, identical bodies | Share one file — content-addressed, stored once |
| Sidecar no longer referenced | Removed when the cassette is next written |
| Sidecar edited by hand | Refused, not replayed as wrong bytes |
| Sidecar missing | Error naming the file that is gone |
| Body files in the cassette directory | Recognised as not-cassettes; the CLI inventory skips them |
Compression
| Situation | Behaviour |
|---|---|
| Gzipped response | Decompressed and stored as readable text |
| The recording run itself | Sees the same decompressed response the replaying run will see — ADR-0014 |
Content-Length | Corrected to describe the decompressed bytes |
deflate | Both spellings found in the wild are accepted |
| Compression is what you are testing | decodeCompressedResponse: false turns it all off |
| Encoding this build cannot decompress | Stored exactly as it arrived — not half-processed, not rejected |
Transport errors
Off by default: a failure reaches the caller and nothing is written. With
recordTransportErrors: true:
| Situation | Behaviour |
|---|---|
| PSR-18 network failure | Recorded in place of a response, category Network |
| PSR-18 request failure | Recorded, category Request |
| Replay | Throws http-vcr’s own exception implementing the matching PSR-18 interface |
| The original client’s exception class | Never reconstructed — ADR-0015 |
| An exception that is neither kind | Not recorded. It propagates untouched |
| A recorded failure on replay | Consumed like any other interaction |
Repeats and consumption
| Situation | Behaviour |
|---|---|
| Same request twice, two recordings | Replayed in the order they were made |
| Asking once more than was recorded | Fails, saying the cassette is exhausted |
Two VcrClient instances | Separate sessions — they do not share consumption |
repeatablePlayback | One interaction answers as often as it is asked |
| A single interaction marked repeatable in the data | Same, for that interaction only |
| A recording session asking twice | Records twice; it never replays what it just recorded — ADR-0009 |
| …unless the cassette is repeatable | Then one recording serves the repeats |
Strict mode
| Situation | Behaviour |
|---|---|
AllPlayed, everything replayed | Passes |
AllPlayed, leftovers | Fails, naming the interactions nothing asked for |
AllPlayed on a cassette the test never touched | Fails |
| A repeatable interaction | Counts as played once it has been replayed at all |
InOrder, sequence matches | Passes |
InOrder, out of order | Fails, naming the pair that came out backwards |
InOrder, something missing entirely | Ignored — that is AllPlayed’s job, not ordering’s |
| What the session recorded itself | Not judged |
| The assertion fails | The lock is still given back — ADR-0012 |
Staleness
| Situation | Behaviour |
|---|---|
| Stale cassette, no enforcement | Replays as usual |
VCR_ENFORCE_STALE_CHECK set | The same cassette becomes a failure, naming the interaction |
| Ignore-stale set as well | Ignoring outranks enforcement |
| Inside the threshold | Passes under enforcement |
| Mixed ages | Only the interaction that outlived the threshold is reported |
| First recording | Enforcement has nothing to say about it |
Two tests declaring different staleAfter for one cassette | Reported and skipped, not silently resolved |
Scoping
| Situation | Behaviour |
|---|---|
| A scope is resolved | It becomes part of the filename |
| Two scopes of one cassette | Separate files, independent locks and counters |
| A scope already on disk | Replays with no real request |
PlaybackOnly with a missing scope | Error lists the scopes that do exist |
| Recording blocked | Blames the variable and still lists the scopes |
| A request the resolver does not scope | Uses the cassette’s own file |
| Strict mode | Checked per scope file, not over one pool |
| A scope that cannot be a filename | Refused, not mangled into a surprising path |
| A scope that can be sanitised | Reduced to a single path segment |
Credentials
| Situation | Behaviour |
|---|---|
| A provider key is missing while recording | The request is stopped before it goes out |
| Only one API being recorded | Only that API’s credentials are required |
| A replaying run | Never asks for credentials it will not use |
| A cassette requiring its own variable | Works with no provider involved |
| Several missing at once | Reported together, not one run at a time |
Redaction and matching
The subtle one: redaction is applied to the incoming request too, so both sides match placeholder-to-placeholder — ADR-0007.
| Situation | Behaviour |
|---|---|
| A secret | Never reaches the cassette file |
| A two-way rule | Gives the code under test the real value back from the response |
| A redacted header, query param or form field | Still matches on replay |
| The recording run | Still sees the real response |
Authorization | Redacted with no configuration at all |
| An auto-redacted header | Stops telling two otherwise-identical requests apart |
includeSensitiveHeaders() | Stores it as sent, so it tells them apart again |
A project-wide rule in http-vcr.php | Applies without touching the client |
Lifecycle
| Situation | Behaviour |
|---|---|
| Configuring after the first request | LogicException naming the method — ADR-0006 |
A satellite from withInner() going out of scope | Does not end the session |
| A request through a satellite | Freezes configuration for the whole session |
close() | Releases the lock and checks strict mode |
__destruct() | Releases the lock only, never asserts |
Forced re-recording (VCR_ERASE_TAPE)
| Situation | Behaviour |
|---|---|
| A named cassette | Recorded from scratch |
| A cassette the selector does not name | Replayed as usual |
| A locked interaction | Spared, and keeps being replayed |
| A provider name | Selects every host it covers, leaving other APIs in the same cassette alone |
| Survivors | Keep their order at the front; fresh recordings follow |
| A fully locked cassette | Left exactly as it was, and says the erase came to nothing |
| Recording disabled | The cassette is left alone rather than erased |
| A bad value for the variable | InvalidArgumentException — not a new exception type |
Decision Records
Architecture decision records: what was decided, what the alternative was, and what it costs. Each one is short and each one names a trade-off — an ADR with no downside section is a press release, not a record.
These sixteen are the decisions that shape the structure of the library and would be
expensive to reverse. They are not the complete set: PLAN.md §7 holds sixty-four resolved
decisions, most of which are API details rather than architecture (whether a configuring
method returns void or self, how a particular exception factory is spelled). Each ADR
below cites the §7 numbers it comes from, so the fuller reasoning is one hop away.
| # | Decision | Shapes |
|---|---|---|
| 0001 | Decorate a PSR-18 client | The whole library |
| 0002 | JSON cassettes, YAML opt-in | On-disk format |
| 0003 | Recording allowed locally, refused on CI | Safety default |
| 0004 | Interactions are snapshots, not PSR-7 objects | Core data model |
| 0005 | Session lock behind an optional interface | Persistence contract |
| 0006 | Configuration freezes at the session | Lifecycle |
| 0007 | Redaction normalises both sides | Matching + redaction |
| 0008 | Redaction is one rule class with a target enum | Hook pipeline |
| 0009 | A session never replays what it just recorded | Recording semantics |
| 0010 | Scoping splits session and manager | Cassette identity |
| 0011 | Guzzle integrates through withInner() satellites | Bridges |
| 0012 | Strict mode verified in close(), never __destruct | Lifecycle |
| 0013 | Large bodies move to sidecar files | Storage |
| 0014 | Decompression applies to the recording run too | Record/replay parity |
| 0015 | Only PSR-18 exception interfaces are recorded | Transport errors |
| 0016 | Laravel bridge in its own repository | Packaging |
The thread running through them
Several of these are the same decision applied in different places. Record and replay must be indistinguishable from the caller’s side produces 0014 (decompress on both runs), 0004 (rebuild responses rather than hand back stored ones) and 0009 (the recording run makes the requests the code actually makes).
State belongs to the session, not the object produces 0006, 0010 and 0011 — all three fall out of the bridges needing to construct clients freely without fragmenting the run.
Optional capability goes in a second interface produces
0005 and the ExplainsMismatch
treatment of matcher diagnostics: the minimum contract stays implementable, and the extra is
detected with instanceof.
Writing a new one
New work is either a PLAN.md §8 idea being promoted — in which case write the decision in
§7 first — or a correction to something already here. If the change alters one of the
sixteen above, amend that ADR with a Superseded by line rather than editing the decision
out of history.
ADR-0001: Decorate a PSR-18 client
Status: Accepted · Reference: PLAN.md §1, §2
Context
A record/replay library has to get between the code under test and the network. There are three well-trodden ways to do that in PHP:
- Patch the transport. Register a
stream_wrapper, or swapcurl_*out from under the process. This is what several established VCR ports do. It catches every request in the process, including ones from code you do not control. - Swap the client implementation. Ship a fake HTTP client the test injects instead of the real one.
- Decorate the client. Wrap whatever client the project already uses in an object with the same interface.
Option 1 catches the most traffic, and pays for it with process-wide state: something has to be installed before the first request and torn down after the last, the teardown has to survive a failing test, and two tests that both want it cannot run in the same process with different settings. Option 2 forces the project to build for testability in a specific shape, and gives up on anything that constructs its own client internally — which most vendor SDKs do.
Decision
VcrClient implements Psr\Http\Client\ClientInterface and takes the real client as a
constructor argument. Nothing else is installed, patched, or registered.
Consequences
Good. Two VcrClient instances in one process never interfere — there is no shared
state for them to interfere through. Nothing needs resetting between tests, and a test that
fails halfway leaves no global mess behind. Anything already speaking PSR-18 works
unchanged: Guzzle 7+, Symfony’s Psr18Client, php-http, Buzz. The decorator is also
trivially inspectable — it is one object, and you can see it in the constructor call.
Bad. Code that builds its own client internally and offers no seam is out of reach.
This is why the bridges exist: Guzzle’s HandlerStack and
Symfony’s HttpClientInterface are the two places where SDKs commonly do accept an
injected piece, and each gets an adapter that reaches the same core.
Also bad, and accepted. Retries, redirects and connection reuse happen below the decorator, inside the real client. A cassette records what the client returned, not what the wire carried. A test that needs to assert on redirect behaviour is testing the client, not your code, and http-vcr is the wrong tool for it.
ADR-0002: JSON cassettes by default, YAML opt-in
Status: Accepted · Reference: PLAN.md §7 decisions 2, 63
Context
Cassettes are committed to the repository and read in code review, so the on-disk format is a user interface, not an implementation detail. YAML is what php-vcr uses and what the ecosystem half-expects. JSON is in the standard library.
Decision
JSON is the default. YAML is available as YamlCassetteSerializer for projects that want
it, and pulls in symfony/yaml only if used. Both are thin spellings over
ArrayCassetteSerializer, which holds the actual schema.
HAR is deliberately not a third serializer — it is an import/export format, handled by
Import/HarCassetteImporter and HarCassetteExporter.
Consequences
Good. The default install has no serialization dependency. JSON diffs are unambiguous —
no significant whitespace, no block-scalar surprises when a recorded body happens to start
with a dash. Because the schema lives in one place, adding a field means touching
ArrayCassetteSerializer once and both formats gain it.
Bad. JSON has no comments, so a hand-annotated cassette is not possible; and long single-line bodies are less pleasant to read than a YAML block scalar. Projects that care can switch, at the cost of one dependency.
Note on schema evolution. Cassettes carry schemaVersion from the very first release,
before there was anything to version. Adding it later would have meant guessing the shape
of files already committed in other people’s repositories.
ADR-0003: Recording is allowed locally and refused on CI
Status: Accepted · Reference: PLAN.md §7 decision 5
Context
The failure mode this library exists to prevent is a test suite that quietly reaches a real API. If recording were simply always allowed, a missing cassette on CI would be repaired by calling the live API — spending quota, mutating remote state, and needing production credentials on a build agent. If recording were always disallowed without an explicit opt-in, the first-run experience would be a failure and a paragraph of setup.
Those two audiences want opposite defaults, and they are reliably distinguishable.
Decision
Recording is permitted by default, and refused when the environment looks like CI.
Detection reads CI, CONTINUOUS_INTEGRATION, BUILD_NUMBER, JENKINS_URL and
TEAMCITY_VERSION — which between them cover GitHub Actions, GitLab CI, CircleCI, Travis,
Buildkite, Jenkins and TeamCity.
VCR_ALLOW_RECORDING overrides in one direction and RecordMode::PlaybackOnly in the
other, so neither default is a trap.
Consequences
Good. No setup on a developer machine: write the test, run it, get a cassette. On CI a
missing cassette raises RecordingNotAllowedException, whose message names the variable
that triggered the detection — so a false positive is diagnosable rather than mysterious.
Bad. The heuristic is a guess about the world, and guesses are wrong sometimes. A
developer whose shell exports CI=1 for unrelated reasons gets a refusal they did not
expect. The mitigation is entirely in the error message: it says which variable was set and
what to do about it, rather than reporting a generic “cannot record”.
Deliberately narrow. The variable list is short and specific rather than a broad pattern
match on anything containing CI. A false positive here blocks work; a false negative only
means a build agent records once, which the missing credentials would stop anyway.
ADR-0004: Interactions are snapshots, not live PSR-7 objects
Status: Accepted · Reference: PLAN.md §3.2, §7 decisions 25, 26
Context
The obvious internal representation of a recorded exchange is the PSR-7 request and
response objects themselves. It is also unworkable. A PSR-7 body is a StreamInterface — a
handle to something that may be a socket, may not rewind, and is very likely to be consumed
by the first thing that reads it. Holding one and expecting to serialize it later means
holding a resource whose contents have already been drained by the code under test.
Decision
The moment a request or response passes through, it is converted to a plain snapshot:
RecordedRequest and RecordedResponse, holding strings and arrays. Interaction is built
through the named constructors Interaction::recorded() and Interaction::failed(), with a
private constructor so no other shape can exist.
Responses handed back to the caller are rebuilt from the snapshot through a PSR-17 factory rather than stored and returned.
Consequences
Good. Serialization is total — there is no stream state that might or might not survive the trip to disk. Matching compares values, not object identity. Replay is deterministic because the response object handed to the test is freshly constructed every time, so a test that consumes the body does not affect the next one that replays the same interaction.
Bad. The library needs a PSR-17 factory to rebuild responses, which is a dependency it
cannot supply itself. Psr17FactoryResolver finds one in whatever the project already
installed (Guzzle, Nyholm, Laminas ship them) and fails at construction time with a clear
message if nothing is available — rather than partway through the first request.
Also. Non-seekable bodies are handled at the snapshot boundary: the body is buffered once, and both the recording and the code under test get a fresh readable stream over the buffered bytes. See Edge Cases.
ADR-0005: The session lock lives behind an optional interface
Status: Accepted · Reference: PLAN.md §7 decisions 19, 20
Context
Two test processes running in parallel can both decide a cassette is missing and both start recording into it. Whoever writes last wins, and the loser’s interactions vanish — or worse, the two interleave into a file that replays as neither run.
A lock fixes it, but not every store can take one. A filesystem can; an object store or an in-memory persister used in the library’s own tests cannot, and a store that cannot lock is still a perfectly good store for replaying.
Decision
CassettePersisterInterface stays the minimum a store must do: read, write, delete,
exists, list, describe. Locking is a second, optional interface —
Persistence\SupportsSessionLocking with lock() and unlock(). A persister that
implements it gets locked recording sessions; one that does not still works for playback.
The lock is held for the whole recording session, not just around the write.
Consequences
Good. Implementing a store stays cheap. The library asks instanceof once and adapts.
Holding the lock for the session rather than the write is what actually prevents interleaving:
a lock taken only at write time would let two runs both read an empty cassette, both record
different interactions, and both write “correctly”.
Bad. A parallel run against a non-locking store has no protection, and the library cannot warn about it usefully because the same store is the right answer for replay-only suites. The trade is documented rather than solved.
Related. describe(string $key): string is on the main interface so exception messages
can say where a cassette was expected — a filesystem path, an object key, whatever the
store considers a location. Without it, “cassette not found” cannot say not found where.
ADR-0006: Configuration freezes at the cassette session
Status: Accepted · Reference: PLAN.md §7 decision 17
Context
VcrClient accepts configuration after construction — redact(), beforeRecord(),
includeSensitiveHeaders(). Anything registered after traffic has already flowed would
apply to some interactions and not others, producing a cassette where half the entries were
redacted and half were not. That is worse than either extreme, because it looks fine.
The question is where to draw the line. Per VcrClient instance is the obvious answer and
the wrong one: the Guzzle bridge produces a new VcrClient per request
(ADR-0011), so an instance boundary would
freeze after every single request.
Decision
The boundary is the cassette session, not the object. Calling a configuring method after
the session’s first request throws LogicException, naming the method and explaining that
an interaction has already been through the pipeline it configures.
Project-wide Config freezes on first use too, via Config::freeze() in the VcrClient
constructor.
Consequences
Good. The rule matches what a reader would assume: everything in the cassette went through the same pipeline. It survives the satellite-instance pattern the bridges rely on, because satellites share the session. The error is loud, immediate, and says what to do.
Bad. Configuration must happen before the first request, which is a real constraint for
code that discovers a redaction rule mid-test. The escape hatch is the config file:
http-vcr.php rules are project-wide and always in place before anything starts.
ADR-0007: Redaction normalises both sides instead of special-casing matchers
Status: Accepted · Reference: PLAN.md §7 decision 9
Context
Redaction replaces a secret with a placeholder before the interaction reaches disk. The
cassette then holds Authorization: Bearer <REDACTED>. On the next run the live request
still carries the real token — so a header matcher comparing the two sees
Bearer sk_live_abc… against Bearer <REDACTED> and reports a mismatch. The cassette is
correct, the request is correct, and matching fails anyway.
Two ways out. Teach the matchers about redaction — every matcher gains a special case for placeholder values. Or make sure both sides look the same by the time matching happens.
Decision
Both sides are normalised. The incoming request goes through the same redaction rules before it is compared, so a redacted field is compared placeholder-to-placeholder. Matchers know nothing about redaction at all.
Consequences
Good. RequestMatcherInterface stays a pure comparison of two RecordedRequests, which
is what makes it plausible to implement from outside. A custom matcher inherits correct
redaction behaviour without knowing redaction exists. Adding a new redaction target does not
mean revisiting every matcher.
Bad. A redacted field stops distinguishing requests. If two requests differ only in a
redacted header, they now look identical and the first recorded interaction answers both.
This is real and occasionally surprising — includeSensitiveHeaders() exists precisely for
it, storing the header as sent so it can tell requests apart again.
Two-way rules. A rule with a value callback restores the real value on playback, so the code under test receives what it expects. A field is only restored when it holds exactly the placeholder — a partially-matching value is left alone rather than guessed at.
ADR-0008: Redaction is one rule class with a target enum
Status: Accepted · Reference: PLAN.md §7 decisions 36, 37, 39
Context
There are five things you can redact: a raw value anywhere, a header, a JSON field by
pointer, a query parameter, and a form field. The instinct is a class per kind —
HeaderRedaction, JsonFieldRedaction, and so on, behind a common interface.
They differ only in where they look. The matching, the placeholder substitution, and the two-way restore are identical in all five.
Decision
One Redaction class holding a RedactionTarget enum: Value, Header, JsonField,
QueryParam, FormField. The differences are a match arm, not a subclass.
Redaction registers itself into HookRegistry when the session is created, which makes it
the first hook in both directions — before any user beforeRecord hook on the way to
disk, and before any beforePlayback hook on the way back.
Consequences
Good. Adding a target is one enum case and one arm. Ordering is a property of when registration happens rather than a priority number someone has to reason about — and the ordering is the one that matters: a user hook inspecting an interaction on its way to disk sees it already redacted, so a hook cannot accidentally leak a secret it was never meant to see.
Bad. The class carries a small amount of per-target branching, and a target needing genuinely different substitution logic would strain the shape. None of the five do.
On JSON fields. A redacted JSON field is substituted by re-encoding the decoded structure, not by string replacement, so a placeholder cannot corrupt the document or accidentally match a substring elsewhere in the body.
ADR-0009: A session never replays what it just recorded
Status: Accepted · Reference: PLAN.md §7 decision 21
Context
A test sends the same request twice against an empty cassette. The first send records interaction #1. The second send now finds a matching interaction on the cassette — the one this very run just wrote a millisecond ago.
Replaying it would mean the recording run and every run after it behave differently: the recording run makes one real request, and the replaying run makes none but sees two responses. A cassette recorded from a test that polls an endpoint until it changes would capture a single response and then replay it forever, which is the opposite of what the test observed.
Decision
Interactions recorded during a session do not participate in matching in that same session. The second send is another miss, makes another real request, and records interaction #2.
Consequences
Good. The recording run makes exactly the requests the code under test makes, in the same order, and the cassette is a faithful transcript. Replay reproduces the recording run rather than an optimised version of it. Polling loops, retries, and pagination all record correctly.
Bad. A test that sends the same request a hundred times records a hundred interactions
and makes a hundred real calls on the recording run. That is the honest cost of a faithful
transcript, and repeatablePlayback exists for the case where you would rather one
recording served the repeats.
Interaction with repeatablePlayback. When the cassette is repeatable the rule relaxes:
one recording does serve the repeats, because the user has explicitly said the interaction
is not order- or count-sensitive.
ADR-0010: Scoping splits the session into two classes
Status: Accepted · Reference: PLAN.md §7 decision 44
Context
Scoping lets one cassette name span several files — keyed by URL, by tenant, by whatever a
resolver returns. tests/Cassettes/orders.json becomes orders.shopify.json and
orders.stripe.json, chosen per request.
That breaks the assumption that “the cassette” is one thing. Some state belongs to the test (the hook pipeline, the redaction rules, whether the first request has gone out) and some belongs to each file separately (the lock, which interactions have been consumed, the strict-mode verdict).
Decision
Two classes. CassetteSession is the cassette as the test named it: it routes requests to
files and owns the test-scoped state. CassetteManager is one file: it owns the file-scoped
state, one instance per scope, and the instances are independent down to the lock.
Without a scope resolver there is exactly one manager and the session is a thin front.
Consequences
Good. Strict mode is checked per file, so “this cassette has an interaction nothing asked for” names the file the leftover is actually in. Locks are per file, so recording into one scope does not block another. Hooks stay per test, which is what a reader expects when they register one.
Bad. Two classes where a smaller library would have one, and the split is invisible until you use scoping. The alternative — pooling consumption counters across scopes — was rejected because it makes strict-mode failures unactionable: it can tell you something was left over, but not where.
Scope names become filenames, so they are sanitised into a single path segment. A scope that cannot be one is refused at resolution time rather than mangled into a surprising path.
ADR-0011: Guzzle integrates through withInner() satellites
Status: Accepted · Reference: PLAN.md §7 decisions 8, 46, 47
Context
Guzzle middleware sits inside a HandlerStack, and the handler it wraps is supplied per
request rather than at construction. A VcrClient built the ordinary way already holds its
inner client, so it does not fit: the middleware knows the real handler only when a request
is already in flight.
Decision
VcrClient::withInner(ClientInterface $inner) returns a cloned satellite instance with the
supplied client and a shared cassette session. The middleware makes one per request.
The satellite’s destructor does not close the session — only the instance that owns it does. The middleware returns a rejected promise rather than throwing, because that is what Guzzle’s stack expects, and its position in the stack is part of the contract.
Consequences
Good. Consumption counters, the lock, hooks and redaction all live on the session, so they behave identically whether the traffic came through one client or fifty satellites. A cassette recorded through the middleware is indistinguishable from one recorded directly.
Bad. “Cloned instance sharing mutable state with its parent” is a shape that has to be
held carefully — it is exactly why session-scoped state was pulled off VcrClient in
ADR-0006 and
ADR-0010. A satellite going out of scope
mid-test must not release the lock, and there is a test asserting precisely that.
Position matters. The middleware has to sit where it sees the request the handler would send. Documented in the Guzzle integration page rather than left for users to discover through a confusing cassette.
ADR-0012: Strict mode is verified in close(), never in the destructor
Status: Accepted · Reference: PLAN.md §7 decision 42
Context
StrictMode::AllPlayed fails a test whose cassette holds interactions nothing asked for;
InOrder fails when replay departed from the recorded sequence. Both are verdicts about a
finished run, so they can only be checked at the end.
“The end” is ambiguous in PHP. There is the moment the test method returns, and there is the
moment the garbage collector reclaims the client — which may be during another test, during
shutdown, or during the handling of an unrelated exception. Throwing from __destruct() at
those moments produces failures attributed to the wrong test, or fatal errors that swallow
the real one.
Decision
close() releases the lock and checks strict mode. __destruct() releases the lock and
nothing else. InOrder is implemented as monotonicity of recorded positions, evaluated at
close.
The PHPUnit bridge closes from an #[After] method in the trait; the Test\Finished
subscriber is only a backstop for clients the trait never saw.
Consequences
Good. An assertion fires at a moment the test chose, and is attributed to the test that caused it. The lock is always given back regardless — including when the strict-mode assertion itself fails, which has its own test. Nothing depends on collection timing.
Bad. A test that never calls close() and does not use the bridge gets no strict-mode
check at all. Silently skipping a requested assertion is unpleasant; the alternative was
raising it from a destructor, which is worse in every way that matters.
ADR-0013: Large bodies move to sidecar files
Status: Accepted · Reference: PLAN.md §7 decision 27
Context
Cassettes are committed and read in review. A recorded PDF, image or multi-megabyte JSON export inlined as base64 turns the file into an unreviewable wall of characters, and every re-record produces a diff nothing can read.
Decision
Bodies past inlineBodyLimit (default 1 MiB) are written to a file of their own beside
the cassette, and the cassette holds a reference. The mechanism is an optional
?SidecarBodies argument on serialize() and deserialize() — a serializer that is handed
one uses it, and one that is not still round-trips a complete cassette.
Bodies are content-addressed, so two interactions with identical bodies share one file. Sidecars nothing references any more are removed when the cassette is written.
Consequences
Good. The cassette stays readable no matter what the API returned. Byte-for-byte replay is preserved. Deduplication means a paginated recording of the same large payload costs one file. Garbage collection on write means the directory does not grow forever.
Bad. A cassette is now potentially several files, and moving one by hand without the others breaks it. Two safeguards: a sidecar whose contents no longer match its reference is refused rather than replayed as wrong bytes, and a missing one produces an error naming the file that is gone. Body files are also recognisably not cassettes, so the CLI’s inventory does not mistake them for one.
Optional by design. Passing the sidecar store as an argument rather than baking it into the serializer interface keeps custom serializers simple — they can ignore the feature and still be correct.
ADR-0014: Decompression changes the recording run’s response too
Status: Accepted · Reference: PLAN.md §7 decisions 28, 29
Context
APIs return gzip. Storing the compressed bytes makes the cassette unreadable and its diffs meaningless, so http-vcr decompresses before recording and stores readable text.
That creates a trap. If decompression happened only on the storage path, the recording run would hand the code under test the original compressed response while every later run handed it decompressed text. The suite would pass on the run that recorded and fail on the next one — the single worst failure mode a record/replay tool can have, because it makes the recording step look successful.
Decision
Decompression applies to the response handed back to the caller as well, not just to what is
written. The recording run sees exactly what replaying runs will see. Content-Length is
corrected to describe the decompressed bytes rather than left describing the compressed
ones.
Whether a body is treated as binary is decided by bodyEncoding — the actual content — not
by trusting Content-Type.
Consequences
Good. Record and replay are indistinguishable from the caller’s side, which is the property the whole library rests on. Cassettes hold readable text. Both spellings of deflate found in the wild are accepted.
Bad. Code that deliberately inspects Content-Encoding sees something different from
what the server sent. For the case where compression is what is under test,
decodeCompressedResponse: false turns the whole behaviour off; there is a test named for
exactly that scenario.
An encoding this build cannot decompress — a missing zlib extension, an exotic codec — is stored exactly as it arrived rather than half-processed or rejected.
ADR-0015: Only the two PSR-18 exception interfaces are recorded
Status: Accepted · Reference: PLAN.md §7 decision 30
Context
With recordTransportErrors: true, a failed request is recorded in place of a response so
the failure replays deterministically. But “a failed request” is not a well-defined set: the
real client can throw anything at all, including bugs in its own code, and a
TypeError from inside Guzzle is not a transport error worth preserving as test data.
Decision
Only exceptions implementing PSR-18’s NetworkExceptionInterface or
RequestExceptionInterface are recorded, stored under an ErrorCategory of Network or
Request. Anything else propagates untouched and nothing is written.
On replay, http-vcr throws its own VcrNetworkException or VcrRequestException,
implementing the matching PSR-18 interface. It never attempts to reconstruct the original
client’s exception class.
Consequences
Good. The recorded set is exactly what PSR-18 defines as a transport failure, which is
portable across clients. Code catching NetworkExceptionInterface — the interface it should
be catching — works identically on both runs. A genuine bug in the client is not silently
frozen into a cassette.
Bad. Code catching GuzzleHttp\Exception\ConnectException by concrete class will not
catch the replayed failure. This is the correct trade: rebuilding a foreign exception class
means guessing at constructor arguments and private state, and would tie cassettes to the
client that recorded them.
Off by default. recordTransportErrors defaults to false, so a failure reaches the
caller and nothing is written — a transient network blip during recording does not become a
permanent fixture.
ADR-0016: The Laravel bridge lives in its own repository
Status: Accepted · Reference: PLAN.md §7 decision 13
Context
A Laravel integration wants a service provider, Http facade interception, and vcr:*
artisan commands. All of it needs illuminate/* packages, and all of it is tied to a
framework release cycle that moves faster than a PSR-18 decorator needs to.
The Guzzle and Symfony bridges, by contrast, live in this repository — their dependencies
are suggested, and the adapter classes are a few hundred lines that only load if the user
constructs them.
Decision
Guzzle and Symfony bridges stay in src/Bridge/. Laravel goes to a separate package,
mtk3d/laravel-http-vcr, listed under suggest.
Consequences
Good. The core keeps a dependency list of PSR interfaces plus Symfony Console and php-parser for the CLI, and its CI matrix is about PHP versions rather than framework versions. A Laravel 12 release is a version bump in one small repository, not a constraint on this one. Users not on Laravel carry nothing.
Bad. Two repositories to release, and a version-compatibility table to keep honest. The
line is drawn at does this need the framework installed to be useful — HandlerStack and
HttpClientInterface are single interfaces from libraries a project may already have, while
a service provider is meaningless without the framework around it.