Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

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

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 implement Psr\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:

PackageNeeded for
guzzlehttp/guzzleThe VcrMiddleware bridge for a Guzzle HandlerStack — plain GuzzleHttp\Client works without it
symfony/http-clientThe VcrHttpClient bridge for Symfony’s native HttpClientInterfacePsr18Client works without it
symfony/yamlThe YAML cassette serializer, for teams that would rather not use the default JSON one
phpunit/phpunitThe #[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-vcrZero-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 Http facade, 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-producttests/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::AllPlayed and InOrder are assertions about a single file. A cassette two tests write into fails them for reasons belonging to the other test.
  • ExtendCassette appends 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:

  1. The record modeRecordIfAbsent, ExtendCassette, or PlaybackOnly — plus two separate, env-only switches: whether recording is permitted at all, and forced re-recording. See Record Modes and the Environment Variables reference.
  2. 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 chainbeforeRecord 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 NoMatchingInteractionException instead 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_RECORDINGResult
set to 1 or 0exactly that — an explicit value always wins
unset, CI detectedrecording blocked
unset, no CI signalrecording 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 CI for 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=0 in 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)

FieldMeaning
schemaVersionFormat 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
recordedAtTimestamp used by staleAfter to flag old recordings
lockedWhen 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=2 equals ?b=2&a=1), but repeated keys keep their order (?tag=a&tag=b is 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) {}
    

    $headers lists the header names to check; empty means every header on the recorded request. exact: true additionally 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 $headers when 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 identical
    

    Both 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:

  1. 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.
  2. 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 to all, or narrows to a single provider — and VCR_ALLOW_RECORDING=1 don’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 stale lists 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 blockedRecordingNotAllowedException, 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.

SerializerExtensionNotes
JsonCassetteSerializer.jsonDefault. Readable diffs in a pull request, no dependencies.
YamlCassetteSerializer.yamlOpt-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 .bin files and .cassette-lock files go through the same persister, and stale/scan-secrets would 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 GzipCassettePersister compressing 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 everything HandlerStack::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 (cassetteDirectory defaults to base_path('tests/Cassettes'))
  • registers the same commands as vendor/bin/http-vcr as Artisan commands, prefixed with vcr: (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 the Http facade, regardless of call site — to install a HandlerStack carrying VcrMiddleware. If the application already sets its own handler, the bridge pushes onto that stack instead of replacing it (from a booted() callback, so it works regardless of provider registration order)
  • narrows the default for VCR_ALLOW_RECORDING with app()->environment(): when the variable isn’t set explicitly, recording is allowed only if the environment is local/testing and 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 with APP_ENV=testing and 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 local and testing environments. 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 testing environment, if http-vcr’s PHPUnit extension isn’t registered in phpunit.xml. Your test calls the Http facade 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 VcrClient and 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.
  • HttpVcrServiceProvider installs the globalOptions() handler once per application boot — which, in tests, means inside each setUp(). The VcrMiddleware it 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 exposes globalRequestMiddleware() / 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 why Http::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 a PendingRequest configured 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 is Http::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:

ClientNotes
GuzzleHttp\Client (Guzzle 7+)only for calls made through sendRequest() — see Guzzle for why that matters
Symfony\Component\HttpClient\Psr18Clientno 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 synchronous sendRequest(). 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 calling curl_exec (or file_get_contents with 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:

  1. 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.
  2. A shorter, more durable name@shopify instead of @shop.myshopify.com, and it survives the domain changing.
  3. Several hosts as one API*.myshopify.com together with shopify.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 the VcrClient constructor), for variables that aren’t tied to a host — and the only option at all in a project with no http-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_TAPE selector — 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 --filter regex 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-producttests/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 VcrClient elsewhere takes a persister argument instead.
  • The CLI resolves it by parsing, not executing. stale, tests and scan-secrets read the attribute from the test files’ syntax tree, following extends across every .php file under testDirectories. 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 under testDirectories. 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;
ParameterDefaultWhat it does
innerThe 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.
cassetteCassette name, without extension or scope suffix. A path relative to cassetteDirectory: shopify/get-producttests/Cassettes/shopify/get-product.json.
modeRecordIfAbsentRecord mode.
matchers[][Method, Uri, QueryString]Matchers, combined with AND. Empty means the project default from config.
strictModeNoneAllPlayed / InOrder assertions at cassette close.
staleAfternullStaleness threshold. null means freshness isn’t tracked.
requiresEnv[]Environment variables that must be set — checked on the recording branch, not at construction. See PHPUnit.
recordTransportErrorsfalseWhether to persist transport failures instead of letting them pass through unrecorded.
decodeCompressedResponsetrueDecompress Content-Encoding: gzip/br/deflate before storing, and strip the header. Turn off only when compression itself is what’s under test.
inlineBodyLimit1048576 (1 MiB)Bodies above this go to a sidecar file instead of into the cassette.
repeatablePlaybackfalseCassette-wide default for whether interactions are consumed on replay; overridable per interaction in the data.
lockedfalseLocks the whole cassette from code, on top of the per-interaction data field.
scopeResolverNullScopeResolverScoping — splits one cassette name across several files by API version.
persister / serializerfrom configWhere and in what format cassettes are stored.
responseFactory / streamFactorydetectedPSR-17, used to rebuild a replayed response. See below.
warnstandard errorWhere 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.
clockSystemClockAny 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:

  1. an explicit constructor argument, then Config/VcrClient::configure();
  2. an implementation detected via class_exists from a closed, enumerated list:
    • Nyholm\Psr7\Factory\Psr17Factory — one class implementing all four interfaces
    • GuzzleHttp\Psr7\HttpFactory — likewise
    • Laminas\Diactoros\ResponseFactory / StreamFactory / RequestFactory / UriFactory — four separate classes, so this provider is resolved interface by interface
  3. failing that, a MissingDependencyException naming 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]),
);
OptionDefaultWhat it does
cassetteDirectorytests/Cassettes/ under the project rootWhere cassettes live. A cassette name is a path inside it.
testDirectoriestests/ under the project rootWhere 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.
scanRecordingsForSecretstrueAfter a session records anything, check the new interactions for credential-shaped values and warn. Never fails a test. See Redacting Sensitive Data.
persisterFilesystemCassettePersisterWhere cassettes are stored.
serializerJsonCassetteSerializerThe 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.
innerClientFactorydetectedBuilds the real PSR-18 client #[UseCassette] uses when it has to record. See below.
PSR-17 factories, clock, scopeResolver, strictMode, staleAfter, inlineBodyLimitsee VcrClient ReferenceThe 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: (stalevcr:stale, testsvcr: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.

LevelEffectDefault
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 Interactionsfalse
VCR_ALLOW_RECORDING0 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-recordunset → 0 when CI is detected, 1 otherwise (see below)
VCR_ERASE_TAPEA 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 belowunset — nothing is erased
VCR_IGNORE_STALE_CASSETTES1 treats every cassette as fresh, whatever recordedAt says — overrides VCR_ENFORCE_STALE_CHECK0
VCR_ENFORCE_STALE_CHECK1 makes a stale cassette fail the test instead of only being reported0

The four variables fall on two independent axes, and only the pairs within an axis can actually contradict each other:

  • Recording: VCR_ALLOW_RECORDING and VCR_ERASE_TAPE.
  • Staleness: VCR_ENFORCE_STALE_CHECK and VCR_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.

SelectorCassettesInteractions in them
shopify/get-productthat oneall
shopify/get-product,shopify/list-productsthose twoall
allevery cassette the run opensall
@shopifyevery cassette the run opensonly those belonging to provider shopify
@shop.myshopify.comevery cassette the run opensonly those sent to that host — every host is implicitly its own provider, so this needs no configuration
sync/order-flow@shopifythat oneonly shopify’s
all@shopifythe 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:

ValueResult
1 or 0exactly that — an explicit value always wins
unset, CI detectedrecording blocked
unset, no CI signalrecording 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:

  • CI
  • CONTINUOUS_INTEGRATION
  • BUILD_NUMBER
  • JENKINS_URL
  • TEAMCITY_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 CI for 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=0 in 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

FieldTypeMeaning
schemaVersionintFormat 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.
interactionsarrayThe recorded interactions, in recording order — order is significant for StrictMode::InOrder.

Per-interaction fields

FieldTypeMeaning
request.methodstringHTTP method.
request.uristringFull URI.
request.headersarray<string, string[]>Header names as recorded, values as a list — supports repeated headers.
request.bodystringRequest body, or "". See the body-encoding fields below.
response.statusintHTTP status code.
response.headersarray<string, string[]>Same shape as request headers.
response.bodystringResponse 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.
errorMessagestring (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.
errorClassstring (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.
recordedAtstring (ISO 8601)When this interaction was recorded — used by staleAfter.
lockedboolSee Locked Interactions.
repeatablePlaybackboolWhen 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.

FieldTypeMeaning
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.
bodyRefstring (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.
bodySha256string (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:

  1. decompression (Content-Encoding stripped, body is plain text)
  2. beforeRecord hooks, including redaction — always on the full, inline content
  3. the inlineBodyLimit check and sidecar write, on the already-redacted content
  4. 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)
ExceptionThrown when
NoMatchingInteractionExceptionThe cassette exists, but no still-unconsumed interaction matches the incoming request. See Matching Requests for the message format.
CassetteNotFoundExceptionThere’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.
StrictModeViolationExceptionAllPlayed or InOrder wasn’t satisfied when the cassette closed.
StaleCassetteExceptionA 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.
MissingEnvironmentVariableExceptionA variable listed in requiresEnv is empty at the moment a real request was about to be made.
MissingDependencyExceptionNo 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.
RecordingNotAllowedExceptionSomething 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.
CassetteFormatExceptionA cassette’s schemaVersion is unknown or newer than this installation understands, or its contents can’t be deserialized.
CassetteIntegrityExceptionA sidecar file doesn’t hash to the bodySha256 recorded for it — hand-edited, truncated, or partially restored from a backup.
VcrNetworkException / VcrRequestExceptionReplaying 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 — PlaybackOnlyCassetteNotFoundException
  • The file is there, but nothing in it matches, or the matching interactions were already consumed → NoMatchingInteractionException, in any mode. RecordIfAbsent against an existing cassette lands here, not on CassetteNotFound
  • A recording would have happened, but VCR_ALLOW_RECORDING=0RecordingNotAllowedException, 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 first VcrClient exists in the process
  • declaring two providers whose host patterns can both match the same host
  • using a VcrClient built with inner: null on a path that needs a real request, without withInner()
  • returning null from a beforePlayback hook

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 for multipart/form-data, since a random boundary makes raw body matching useless.
  • CookieMatcher / SetCookieMatcher — structural parsing of Cookie/Set-Cookie instead of treating them as opaque header strings.
  • Pattern-based header matching — a header equivalent of matchJsonField, for headers like Idempotency-Key or traceparent that shouldn’t be matched exactly or ignored entirely.
  • Configurable query string matchingordered / 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: N instead 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-wide redact in Config. Coherent since providers became a core concept, but deliberately deferred: the common case is already covered by the default Authorization redaction, 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 like list/info without 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

ActorWhat they doWhat they must have
DeveloperRuns the suite locally, records the cassette the first time a test needs one, commits itReal API credentials in the environment, network access
CI pipelineRuns the same suite in replayNeither. 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:

ContainerLives forConsequence
VcrClientOne instance, possibly many per testCheap to construct; holds no cross-test state
Cassette sessionOne testOwns the lock, the consumption counters, the strict-mode verdict
Cassette fileThe repository’s lifetimeMust 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:

InterfaceFor
Matching\RequestMatcherInterfaceDeciding whether a recorded request is this request. Add ExplainsMismatch to get your reason into the failure message
Persistence\CassettePersisterInterfaceStoring cassettes somewhere other than the filesystem. Add SupportsSessionLocking if the store can hold an exclusive lock
Serializer\CassetteSerializerInterfaceA different on-disk spelling of the same schema
Scope\CassetteScopeResolverInterfaceSplitting 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.

SituationBehaviour
Request body cannot be rewoundBuffered 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 rewoundBuffered the same way. The code under test gets a fresh readable stream, not the drained original
Body is seekableHanded 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

SituationBehaviour
Binary response (PDF, image, gzip under test)Stored base64-encoded, flagged in bodyEncoding
TextLeft readable in the file, so diffs stay reviewable
Claims to be text but is not valid UTF-8Treated as binary. The decision is made on the content, not on Content-Type
Binary request bodyEncoded too — the rule is not response-only
Empty bodyNever 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.

SituationBehaviour
Body over the thresholdWritten beside the cassette; the cassette keeps a reference
Replay of a sidecar bodyByte-for-byte identical to what was recorded
Body under the thresholdStays inline in the cassette
Two interactions, identical bodiesShare one file — content-addressed, stored once
Sidecar no longer referencedRemoved when the cassette is next written
Sidecar edited by handRefused, not replayed as wrong bytes
Sidecar missingError naming the file that is gone
Body files in the cassette directoryRecognised as not-cassettes; the CLI inventory skips them

Compression

SituationBehaviour
Gzipped responseDecompressed and stored as readable text
The recording run itselfSees the same decompressed response the replaying run will see — ADR-0014
Content-LengthCorrected to describe the decompressed bytes
deflateBoth spellings found in the wild are accepted
Compression is what you are testingdecodeCompressedResponse: false turns it all off
Encoding this build cannot decompressStored 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:

SituationBehaviour
PSR-18 network failureRecorded in place of a response, category Network
PSR-18 request failureRecorded, category Request
ReplayThrows http-vcr’s own exception implementing the matching PSR-18 interface
The original client’s exception classNever reconstructed — ADR-0015
An exception that is neither kindNot recorded. It propagates untouched
A recorded failure on replayConsumed like any other interaction

Repeats and consumption

SituationBehaviour
Same request twice, two recordingsReplayed in the order they were made
Asking once more than was recordedFails, saying the cassette is exhausted
Two VcrClient instancesSeparate sessions — they do not share consumption
repeatablePlaybackOne interaction answers as often as it is asked
A single interaction marked repeatable in the dataSame, for that interaction only
A recording session asking twiceRecords twice; it never replays what it just recorded — ADR-0009
…unless the cassette is repeatableThen one recording serves the repeats

Strict mode

SituationBehaviour
AllPlayed, everything replayedPasses
AllPlayed, leftoversFails, naming the interactions nothing asked for
AllPlayed on a cassette the test never touchedFails
A repeatable interactionCounts as played once it has been replayed at all
InOrder, sequence matchesPasses
InOrder, out of orderFails, naming the pair that came out backwards
InOrder, something missing entirelyIgnored — that is AllPlayed’s job, not ordering’s
What the session recorded itselfNot judged
The assertion failsThe lock is still given back — ADR-0012

Staleness

SituationBehaviour
Stale cassette, no enforcementReplays as usual
VCR_ENFORCE_STALE_CHECK setThe same cassette becomes a failure, naming the interaction
Ignore-stale set as wellIgnoring outranks enforcement
Inside the thresholdPasses under enforcement
Mixed agesOnly the interaction that outlived the threshold is reported
First recordingEnforcement has nothing to say about it
Two tests declaring different staleAfter for one cassetteReported and skipped, not silently resolved

Scoping

SituationBehaviour
A scope is resolvedIt becomes part of the filename
Two scopes of one cassetteSeparate files, independent locks and counters
A scope already on diskReplays with no real request
PlaybackOnly with a missing scopeError lists the scopes that do exist
Recording blockedBlames the variable and still lists the scopes
A request the resolver does not scopeUses the cassette’s own file
Strict modeChecked per scope file, not over one pool
A scope that cannot be a filenameRefused, not mangled into a surprising path
A scope that can be sanitisedReduced to a single path segment

Credentials

SituationBehaviour
A provider key is missing while recordingThe request is stopped before it goes out
Only one API being recordedOnly that API’s credentials are required
A replaying runNever asks for credentials it will not use
A cassette requiring its own variableWorks with no provider involved
Several missing at onceReported 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.

SituationBehaviour
A secretNever reaches the cassette file
A two-way ruleGives the code under test the real value back from the response
A redacted header, query param or form fieldStill matches on replay
The recording runStill sees the real response
AuthorizationRedacted with no configuration at all
An auto-redacted headerStops telling two otherwise-identical requests apart
includeSensitiveHeaders()Stores it as sent, so it tells them apart again
A project-wide rule in http-vcr.phpApplies without touching the client

Lifecycle

SituationBehaviour
Configuring after the first requestLogicException naming the method — ADR-0006
A satellite from withInner() going out of scopeDoes not end the session
A request through a satelliteFreezes 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)

SituationBehaviour
A named cassetteRecorded from scratch
A cassette the selector does not nameReplayed as usual
A locked interactionSpared, and keeps being replayed
A provider nameSelects every host it covers, leaving other APIs in the same cassette alone
SurvivorsKeep their order at the front; fresh recordings follow
A fully locked cassetteLeft exactly as it was, and says the erase came to nothing
Recording disabledThe cassette is left alone rather than erased
A bad value for the variableInvalidArgumentException — 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.

#DecisionShapes
0001Decorate a PSR-18 clientThe whole library
0002JSON cassettes, YAML opt-inOn-disk format
0003Recording allowed locally, refused on CISafety default
0004Interactions are snapshots, not PSR-7 objectsCore data model
0005Session lock behind an optional interfacePersistence contract
0006Configuration freezes at the sessionLifecycle
0007Redaction normalises both sidesMatching + redaction
0008Redaction is one rule class with a target enumHook pipeline
0009A session never replays what it just recordedRecording semantics
0010Scoping splits session and managerCassette identity
0011Guzzle integrates through withInner() satellitesBridges
0012Strict mode verified in close(), never __destructLifecycle
0013Large bodies move to sidecar filesStorage
0014Decompression applies to the recording run tooRecord/replay parity
0015Only PSR-18 exception interfaces are recordedTransport errors
0016Laravel bridge in its own repositoryPackaging

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:

  1. Patch the transport. Register a stream_wrapper, or swap curl_* 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.
  2. Swap the client implementation. Ship a fake HTTP client the test injects instead of the real one.
  3. 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 usefulHandlerStack and HttpClientInterface are single interfaces from libraries a project may already have, while a service provider is meaningless without the framework around it.