Storage Backends Reference
One-liner: cassettes are files on disk, serialized as YAML (default), JSON, or nowhere at all (Blackhole).
On this page: yaml · json · blackhole · encrypted · Custom storage backend · Cassette file naming
Select via setStorageFactory(). yaml, json, and blackhole
implement PurgeableStorageInterface directly, so all three work with MODE_ALL;
encrypted wraps one of them and works with MODE_ALL whenever the wrapped backend does.
yaml
- Default.
- Factory:
\VCR\Storage\YamlStorageFactory - One YAML list entry per recording, appended as it's recorded — streamed one record at a time rather than parsed whole into memory.
-
request:
method: GET
url: 'http://example.com/hello'
headers:
Host: example.com
response:
status: { code: 200, message: OK }
headers: { Content-Type: text/plain }
body: 'Hello, php-vcr!'
index: 0
⚠️ Warning: very large requests/responses can hit a PCRE backtrack-limit segfault in Symfony's YAML parser. Raise
pcre.backtrack_limitinphp.ini, or switch tojson.
json
- Factory:
\VCR\Storage\JsonStorageFactory - Pretty-printed JSON array, parsed/written incrementally (character-by-character) rather than all at once.
[
{
"request": { "method": "GET", "url": "http:\/\/example.com\/hello", "headers": { "Host": "example.com" } },
"response": { "status": { "code": 200, "message": "OK" }, "body": "Hello, php-vcr!" },
"index": 0
}
]
blackhole
- Factory:
\VCR\Storage\BlackholeStorageFactory - Discards everything.
storeRecording()andpurge()are no-ops,isNew()always returnstrue, and the iterator is always empty — nothing is ever replayed.
📌 Note: unlike
yaml/json, Blackhole never touches the filesystem at all — no cassette file is created, even afterinsertCassette().
- Useful for smoke-testing library-hook behaviour without leaving cassette files behind.
encrypted
🆕 Since 1.13
- Factory:
\VCR\Storage\EncryptedStorageFactory— wraps another storage factory so every cassette it writes has its sensitive fields encrypted, and every cassette it reads is decrypted before request matching runs. - Requires
ext-sodium; encrypts with XChaCha20-Poly1305, keyed by a 32-byteEncryptionKey. method,url, and status stay in plaintext on purpose — the cassette is still reviewable in a diff.
$key = \VCR\Storage\Encryption\EncryptionKey::fromBase64($_SERVER['VCR_CASSETTE_KEY']);
\VCR\VCR::configure()->setStorageFactory(
\VCR\Storage\EncryptedStorageFactory::withKey(new \VCR\Storage\YamlStorageFactory(), $key)
);
Generate a key once and keep it outside the repository — losing it makes every cassette encrypted with it unrecoverable:
$key = \VCR\Storage\Encryption\EncryptionKey::generate();
echo $key->toBase64(); // store this, e.g. as the VCR_CASSETTE_KEY environment variable
A recorded cassette looks like this — the request body and the Authorization header are replaced by an
opaque vcr:enc:v1:... value, while method and url stay readable:
-
request:
method: POST
url: 'http://example.com/post'
headers:
Authorization: 'vcr:enc:v1:lQDi6mIvZalIRRBCbPFAC5kslFL6PZh3YQfc+ojigif6k/rdDP+zcqsB10UMQEXM0sZS...'
body: 'vcr:enc:v1:FGnejCxN3ST4WPgtgd5ZY+QnqmY2zVx63Kf7QJ/9OLGUFX+7yVeifQr3gfLztL8tPD6MXsll1RFsig=='
response:
status: { code: 200, message: OK }
body: 'vcr:enc:v1:lQVZ9S4OFm/oOyNLaSjv2vDKl0ifq8ZbBxl9nvXLQHAwyYSN/jvZjF5qvhiOTm0agNjjz6pH5BoFegy...'
index: 0
Policy defaults
\VCR\Storage\Encryption\EncryptionPolicy decides which fields are encrypted. Unless a custom policy is
passed as the third argument to EncryptedStorageFactory::withKey(), it encrypts:
- Fields:
request.body·request.post_fields·request.post_files·response.body·response.curl_info.request_header - Headers (matched case-insensitively, in both the request and the response):
Authorization·Proxy-Authorization·Cookie·Set-Cookie·X-Api-Key
\VCR\Storage\EncryptedStorageFactory::withKey(
new \VCR\Storage\YamlStorageFactory(),
$key,
new \VCR\Storage\Encryption\EncryptionPolicy(['response.body'], [])
);
⚠️ Limitations
- Secrets in the query string stay in plaintext —
urlis intentionally left readable.- Identical values in the same field produce identical ciphertext, the cost of a deterministic nonce (it keeps a re-recorded cassette byte-identical instead of producing a spurious diff).
- A cassette written with a since-lost key cannot be decrypted again — keep the key itself out of the repository, and keep a backup of it.
- Other
curl_infosub-fields besidesrequest_header(e.g. timing data) are not covered by the default policy — they generally don't carry secrets.
Works with both yaml and json as the wrapped backend.
Custom storage backend
🆕 Since 1.12
Any backend — not just files on disk — can serialize cassettes as long as it implements
\VCR\Storage\StorageFactoryInterface:
interface StorageFactoryInterface
{
public function create(string $cassettePath, string $cassetteName): StorageInterface;
}
create() returns a \VCR\Storage\StorageInterface — an \Iterator over recorded request/response pairs plus
storeRecording() and isNew(). Implement \VCR\Storage\PurgeableStorageInterface (adds purge()) instead if
the backend should support MODE_ALL.
The three built-ins above are ordinary StorageFactoryInterface implementations and a good template to
start from. See Custom storage backend for two runnable examples, including one
backed by a database.
Cassette file naming
- The file is named exactly the cassette name passed to
insertCassette()— no.yml/.jsonextension is appended automatically. - A cassette name containing a path separator (e.g.
'api/users') auto-creates the subfolder under the configured cassette path. - Every recorded entry carries an
index(see Cassettes → identical requests).
← Library Hooks · Next: Events →