Skip to content

Record, replay & regression tests

quioteframework/replay answers a question that is otherwise expensive to answer: what exactly did that one request do, and does it still do it?

A middleware watches requests as they run in a real deployment. When one matches the sampling policy — by default, only when it errors — everything that request consisted of is written to a single file: the method, URI, headers, cookies and body that came in; the route, action and validated parameters the request resolved to; the database queries the action ran, with their bound parameters and the rows they returned; and the response that went back out, or the exception that escaped instead. That file is a cassette.

Given a cassette, quiote replay <id> dispatches that same request through your application again — on a laptop, in CI, anywhere — and reports every way the outcome differs from the recording. quiote replay <id> --as-test turns it into an ordinary PHPUnit test file you commit.

Terminal window
php bin/quiote cassette:list --status=5xx # what got recorded
php bin/quiote replay CRX2050 # dispatch that request here; report what changed
php bin/quiote replay CRX2050 --as-test # freeze it as tests/Replay/ReplayCRX2050Test.php

A replay is isolated by default: nothing the request did to the outside world is done again. Database queries are answered from the rows the cassette recorded, the clock is frozen at the instant of recording so every now()-dependent branch takes the path it originally took, and the stubs standing in for the cache, the queue, outbound HTTP and environment reads refuse to invent a value they have no recording for rather than quietly making one up. That is what makes a recorded POST /orders safe to re-run on every CI build: no database, no payment provider, no order created. Which requests can actually replay this way is narrower in this release than that paragraph suggests — what isolation covers, and what it does not is worth reading before depending on it.

Two workflows, one artifact:

  • Reproduce a production bug. A request 500s. The recorder kept a cassette because the response was an error. Copy the id out of your logs, run quiote replay <id> --as-test, and you have a test that fails the same way locally — no database, no upstream API, no session store involved beyond what the cassette itself carries.
  • Pin current behaviour. Record a representative set of real requests (in staging, or in production behind the error sampling policy), emit tests from all of them, commit. A later change to routing, validation, rendering or serialization that alters the response for a real-world input now fails in CI, with the offending request in hand.
Terminal window
composer require quioteframework/replay:^4.0@RC
Config/plugins.php
return [
['class' => \Quiote\Replay\ReplayPlugin::class, 'enabled' => true],
];

Installing and enabling the package alone changes nothing observable — replay.enabled defaults to false and replay.record to never, and RecorderMiddleware is a single enum comparison and a return when either says so. Recording needs both:

Config/settings.php
return [
'replay.enabled' => true,
'replay.record' => 'error',
];

The middleware registers itself (phase: bootstrap, priority 1100) — there is no middleware config to edit. It sits between StealthMiddleware and ErrorHandlingMiddleware, so it sees the rendered error response and also catches an exception that escapes error handling entirely.

replay.record decides which requests actually become cassettes:

ValueBehaviour
never (default)Nothing is recorded.
errorBuffer every request, but only write a cassette when the response is 5xx or an exception escaped. Nothing is serialized or written for the requests that succeed — the recommended default for production.
rateKeep with probability replay.sample_rate (0.01.0). The roll happens at request entry, not at the end: the rate does not depend on the outcome, so a lost roll skips the body copy, the upload digests and the effect ledger rather than performing them and discarding the result.
headerKeep only when the header named by replay.trigger_header (default X-Quiote-Record) is present. Development/staging only — this is not an authentication check, so don’t expose it in production without an authenticated gate in front of it.
alwaysKeep everything. Development only.

An unrecognised value throws rather than silently falling back to never — a typo must not silently disable a feature that already-happened-in-production debugging depends on.

A cassette is one gzipped JSON document with a .qcast extension and a _schema_version field (currently 1). CassetteCodec understands exactly one version and refuses a newer one outright rather than best-effort parsing it. Decoding is bounded: a cassette is untrusted input and gzip’s ratio is unbounded, so inflation is incremental against a 32 MiB ceiling — a hostile or corrupt cassette raises a normal exception instead of exhausting memory_limit and taking cassette:list down with it.

meta, request and response are the only sections a cassette must carry; the rest are optional and absent when not captured.

SectionContents
metaId, recorded_at, PHP version, context name, the trigger that kept it, and the four honesty flags below.
requestMethod, URI, protocol, headers, cookies, body, uploads, and an allowlisted subset of server params (REQUEST_METHOD, REQUEST_URI, SERVER_PROTOCOL, HTTP_HOST, REMOTE_ADDR, SERVER_NAME, SERVER_PORT, REQUEST_TIME_FLOAT). Captured at entry, before anything mutates it. An upload is recorded as its field name, client name, media type, size and a streaming SHA-256 — never its content.
resolvedRoute name, module/action, route params, output type, the validation report if validation failed, and validated_params: the parameter set that survived validation. That last field is usually the single most useful one for debugging a “my parameter vanished” surprise, since it shows what the action actually received rather than what the client sent.
sessionThe session id and whether it existed. SessionBagInterface has no key-enumeration method, so before and after carry the same end-of-request snapshot rather than a genuine diff.
effectsAn ordered ledger of side effects the request performed — see The effect ledger.
responseStatus, headers, body.
exceptionClass, message, file, line and a stack trace, when one escaped.

Four meta flags exist so a reader can tell an incomplete recording apart from a request that genuinely did that little — without them, a replay reports the recorder’s own gaps as drift in the application:

FlagMeans
effects_instrumentedWhether any effect source was registered at all. false says “nothing was watched”, not “nothing happened”.
effects_truncatedreplay.max_effects or the ledger’s byte budget dropped something.
request_body_truncatedThe request body exceeded replay.max_bytes. A cassette with this set cannot be replayed — replaying a prefix and calling it the request would attribute the recorder’s truncation to the application. Re-record with a larger replay.max_bytes.
response_body_truncatedThe response body exceeded replay.max_bytes. Such a cassette still replays; the body diff compares the recorded prefix and reports a mismatch as a warning rather than an error.

The stack trace is built from getTrace(), one line per frame, not getTraceAsString(). PHP’s trace-as-string embeds each frame’s scalar arguments, so a connection failure would record PDO->__construct('mysql:…', 'user', 'hunter2') in the one section nothing in replay.redact.* can reach — and the section most likely to be present, since the error trigger exists to capture exactly those requests.

A cassette’s id is the request’s correlation id, or a freshly generated one when the request carried no correlation header. That id is untrusted input — sanitizing it strips control bytes but passes /, . and .. through — so a store never keys on it directly: an id that is already [A-Za-z0-9_-]{1,64} is used as the key verbatim (readable in a directory listing), and anything else is reduced to its SHA-256 digest. The raw value is still kept in meta.id, since that is what a human matches against a log line. It is also why an emitted test is occasionally named after a 64-character hash rather than a legible id.

An action that must never be recorded — payment or credential handling, where a body’s sensitive field names aren’t knowable in advance — carries an attribute:

use Quiote\Replay\Attribute\NoRecord;
#[NoRecord]
final class Checkout extends Action
{
// ...
}

A #[NoRecord] action’s cassette keeps only a metadata skeleton: method, URI, module, action. No headers, cookies, body, uploads, server params, session, response body or exception. The attribute is read off the resolved action’s class name by reflection, so nothing is instantiated a second time to answer the question.

Redaction runs at capture time, before anything is buffered — not deferred to write time, since a value sitting unredacted in process memory can still leak through a later dump. The denylists are matched case-insensitively:

KeyDefaultMatches
replay.redact.headersauthorization, cookie, set-cookie, proxy-authorization, x-api-keyRequest and response header names, and outbound header names on a recorded HTTP effect.
replay.redact.paramspassword, password_confirm, token, secret, card, cvv, ssnParameter names (at any nesting depth), cookie names, and bound/fetched database column names where the driver can supply one.
replay.redact.session_csrf, auth.tokenSession snapshot keys.
replay.redact.envpassword, passwd, secret, token, key, credential, private, auth, dsn, connection_string, connectionstring, salt, certEnvironment variable names, matched as substrings — env vars are named per deployment (APP_DB_PASSWORD, STRIPE_SECRET_KEY), so an exact-match list would have to enumerate every name in every app.

The scrubbing happens at two points, deliberately: RecorderMiddleware covers the request envelope, and EffectRedactor sits on the effect ledger itself — the one place every recorder in every driver package already funnels through, so a newly written recorder cannot forget to redact.

replay.redact.mode controls how a matched value is replaced:

ModeResultNote
drop (default)[REDACTED]Discloses nothing.
hashsha256:… of replay.redact.hash_salt + the valueUseful for confirming two cassettes carried the same secret without seeing it. Set the salt. An unsalted digest is not a redaction for a low-entropy value, and the shipped denylist covers exactly those: a three-digit cvv falls to a thousand guesses.
maskAll but the last four characters replaced with *The weakest of the three: it discloses the value’s exact length and its last four characters.

An unrecognised mode throws, for the same reason an unrecognised sampling policy does.

A cassette’s effects section is an ordered list of side effects the request performed, each with a fingerprint (normalized SQL plus a hash of bound parameters for a query; method + URI + body hash for HTTP; op:key for a cache call; the variable name for an environment read), the call’s own description, the result, and how long the real call took.

The ledger is what makes a replay more than a response diff. During recording it is append-only; during replay it is read-only, and every call the code makes is matched against it — so “the code asked for something the recording has no counterpart for” and “the recording holds something the code no longer asks for” both become answerable.

Effect kindRecorded during a live requestSubstituted during isolated replay
dbYes, with a driver package installed (below)Yes, for Doctrine and Propulsion
http, cache, queue, envNoYes
clockNo (the replay clock is frozen instead — see below)
mail, entropy, sessionNoNo

The database is the one subsystem wired end to end. The http/cache/queue/env recorders (RecordingHttpTransport, RecordingCache, RecordingQueueDriver, RecordingEnvironmentReader) exist and are unit-tested, but nothing substitutes them for an application’s live client, cache, queue or environment reader in this release, so those effects are absent from every cassette a 4.0.0-RC1 recorder writes. What that means in practice is spelled out under Replaying in isolation — it is the sharpest edge on the feature today.

Installing quioteframework/replay alone gives you the HTTP request and response, but not what the request read or wrote in the database — and “fetch a row, do something buggy with it, crash” is the dominant real-world crash shape. A driver package for your database adapter adds it, with no code changes beyond enabling the plugin:

PackageRecords fromRecords rowsCan isolate
replay-propulsionPropulsion’s process-wide query observerYesYes — by substituting the connection
replay-doctrineA DBAL driver middlewareYesYes — the decorator is called instead of the real statement
replay-eloquentThe QueryExecuted eventNoNo
replay-cycleCycle’s PSR-3 query loggerNoNo

Each records SQL text, bound parameters and — where the seam allows — the fetched rows, redacted the same way request parameters are. A recorded DB result distinguishes three states explicitly: rows: null means the recorder cannot see rows at that layer, rows: [] means the query genuinely returned none, and affectedRows is kept for a write even when rows are also present.

Register a driver package’s plugin after its underlying database adapter’s plugin: it overrides that adapter’s driver alias to a recording subclass, and the override is last-writer-wins. See Databases for the adapters themselves and Official packages for each package’s exact registration.

Eloquent’s QueryExecuted event and Cycle’s PSR-3 logger both fire after the query has run and its rows have already gone back to the caller. There is no point at which either could return a recorded result instead, and neither ORM offers a connection-level substitution to fall back on. An isolated replay through them would read from — and write to — the real database while appearing isolated, so quiote replay refuses to run in isolated mode and names the package rather than degrading quietly. Their cassettes are still perfectly good for the response diff and for cassette:show; use --live (deliberately), or record through replay-doctrine/replay-propulsion, to replay in isolation.

replay.store names the store. The store is selected by configuration, not by plugin load order: each store package contributes its alias and a factory, and ReplayPlugin’s single CassetteStoreInterface binding builds whichever alias replay.store names. Installing a store package therefore does not commit an application to it, and load order does not matter.

AliasPackageFor
file (default)built inDevelopment. A zero-dependency default; never right in a container whose filesystem doesn’t survive a restart or a scale-down.
pdoreplay-pdoA team with no object store that still wants cassettes to outlive a pod.
azure-blobreplay-azure (on replay-storage)Production on AKS + Azure Blob + Log Analytics.
KeyDefaultMeaning
replay.store.pathvar/cassettesDirectory for the file store. A relative path anchors to core.app_dir, and is refused if there is no core.app_dir to anchor it to — a store whose location is decided by the process working directory is not a location anyone chose.

The directory is created 0700 at construction; one that already exists with group or other access is narrowed to 0700, and refused if it cannot be. Each cassette is written to a temp file created 0600 before anything is written into it, then renamed into place, so a reader never sees a partial cassette. A path inside {core.app_dir}/pub is refused outright — a cassette can carry request bodies and session data and must never be web-servable.

quioteframework/replay-pdo keeps cassettes in the database you already have.

Terminal window
composer require quioteframework/replay-pdo:^4.0@RC

Register Quiote\Replay\Store\Pdo\ReplayPdoPlugin (order irrelevant), then:

KeyDefaultMeaning
replay.storefileSet to pdo.
replay.store.pdo.connectionmainWhich configured connection (Config/databases.*) to use. Its adapter must expose a raw PDO handle (getPdo()) — a plain pdo connection, or an ORM adapter layered on PDO.
replay.store.pdo.tablequiote_cassettesTable name.

The table is not created automatically — run the DDL PdoCassetteStore::schema() returns as a migration:

CREATE TABLE IF NOT EXISTS quiote_cassettes (
slug VARCHAR(64) NOT NULL PRIMARY KEY,
raw_id VARCHAR(255) NOT NULL,
recorded_at VARCHAR(32) NULL,
route VARCHAR(255) NULL,
status INTEGER NULL,
trigger_reason VARCHAR(32) NULL,
payload TEXT NOT NULL
);

PostgreSQL and SQLite only: the store’s upsert is INSERT … ON CONFLICT, and MySQL/MariaDB would need ON DUPLICATE KEY UPDATE instead. The gzipped payload is not valid UTF-8, so it is base64-encoded into a plain TEXT column rather than a driver-specific BYTEA/BLOB, because one CREATE TABLE string cannot name a binary type both engines accept.

recorded_at/route/status/trigger_reason are extracted from the cassette at write time so the raw table is legible and queryable by hand (SELECT * FROM quiote_cassettes WHERE status >= 500). cassette:list/cassette:prune still decode every cassette and filter in PHP, exactly as they do against the file store, so both stores share one filtering implementation.

quioteframework/replay-storage implements a cassette store over any Quiote\Storage\ListableObjectStoreClientInterface — Azure Blob, S3 or GCS — and quioteframework/replay-azure wires up the Azure Blob half of it, plus the index chain below.

Terminal window
composer require quioteframework/replay-azure:^4.0@RC

Register Quiote\Replay\Store\Azure\ReplayAzurePlugin and set replay.store to azure-blob:

KeyDefaultMeaning
replay.store.azure.account''Storage account name.
replay.store.azure.containerquiote-cassettesBlob container.
replay.store.azure.authshared_keyshared_key, workload_identity, cli or chain — see cloud-azure.
replay.store.azure.account_key''Only for shared_key.
replay.store.azure.endpoint''Override the blob endpoint (Azurite, a private endpoint).
replay.store.azure.prefixquiote-cassettesFirst key segment.
replay.store.azure.env''The environment segment of a key. Empty means this process’s own core.environment — which is right for the deployment doing the recording, and wrong for a laptop reading its cassettes, hence the override.
replay.store.azure.lookback_hours48How far back a bare-id lookup probes.

Cassettes are written to a deterministic, time-partitioned key derived from the cassette’s own recorded_at, forced to UTC:

{prefix}/{env}/{yyyy}/{mm}/{dd}/{hh}/{id}.qcast

Partitioning by the recorded hour rather than the write time means the same cassette resolves to the same key a day later from any timezone, and a lifecycle rule or a “what happened this hour” listing is a prefix away. get()/has()/delete() take a bare id, which carries no date, so they probe backward hour by hour from now with a cheap head() per hour, up to lookback_hours. That makes the plain store contract work with no extra machinery; the index chain is the faster path, not a prerequisite.

Two consequences worth stating plainly: cassette:list against this store enumerates only the same lookback window, so an older cassette exists and is still fetchable by key but will not be listed; and delete() removes every copy of a slug it finds across hour partitions, not just the newest, because one id can legitimately be re-recorded into a second hour.

Every write also emits a pointer log linecassette stored, at error when the trigger was an error and info otherwise — carrying the id, store alias, container, key, size, status and route, and nothing else. No headers, no body, no parameters: the log line is the index, and it stays safe in a log sink with a wider audience than the cassette container itself.

An id copied out of a log viewer is resolved in a fixed order, by both quiote cassette:fetch and quiote replay:

  1. The local cache (replay.local_path, default var/cassettes) — no network at all.
  2. The configured store — unless --key was given, which is an exact key and goes straight to step 3.
  3. The cassette index chain, using whatever hints were supplied.

Whatever step 2 or 3 resolves is written into the local cache before returning, so a second lookup for the same id is offline. replay.local_path is deliberately a different setting from replay.store.path: a remote-store deployment still gets a fast local copy once fetched.

The index chain is contributed by replay-azure. Each index either resolves the cassette, declines (nothing to try — not configured, no matching hint, or a legitimate zero-result lookup) so the next one runs, or fails loudly. A failure is recorded and also falls through, so one broken index never blocks the others — but if every index declines or fails, the aggregate error names each failure rather than saying a flat “not found”.

IndexNeedsResolves
ExplicitKeyIndex--key, pasted from a pointer log lineStraight to that object. A key that resolves to nothing is an error, not a decline — you pointed at a specific place.
LogAnalyticsIndexreplay.index.log_analytics.workspace_idA bare id, no hints: queries the workspace for the recorder’s own pointer line, reads cassette_key off it, fetches that object. A pointer found whose object has since been pruned throws — “it existed and is gone now” is a materially more useful answer than “not found”.
PrefixScanIndex--date (optionally --hour)A delimited listing of that UTC day’s hour buckets, then a scan for the slug. Needs blob read only — the right fallback for a developer with a storage RBAC grant but no workspace access.
KeyDefaultMeaning
replay.index.log_analytics.workspace_id''Empty makes the index a permanent, cost-free decline: it builds neither a query client nor a blob client.
replay.index.log_analytics.endpointhttps://api.loganalytics.io
replay.index.log_analytics.lookback_hours720The KQL query’s ago() window — log retention outlives blob retention, hence the wider default.
Terminal window
php bin/quiote cassette:list [--since=<iso8601>] [--status=<code-or-class>] [--route=<name>] [--json]

Enumerates the configured store, newest first. --status accepts an exact code (500) or a class (5xx). --since is compared as an instant, not as a string. A cassette that fails to decode is reported as a warning rather than taking the listing down.

Terminal window
php bin/quiote cassette:show <id> [--section=<name>] [--include-bodies] [--raw] [--json]

Prints one cassette’s sections: meta, request, resolved, session, user, effects, response, exception, log. Bodies are excerpted to a length and a sha256 and an effect’s captured rows to a count; --include-bodies turns both back into full content. --raw treats the argument as a path to an already-uncompressed (plain JSON) cassette file, bypassing the store.

cassette:show, cassette:list and cassette:prune read the configured store only — they do not walk the index chain, and --raw expects uncompressed JSON rather than a .qcast. Only cassette:fetch and replay resolve an id the store itself cannot find.

Terminal window
php bin/quiote cassette:fetch <id> [--key=<store-key>] [--date=<yyyy-mm-dd>] [--hour=<00-23>] [--to=<dir>] [--json]

Resolves an id through the chain above and caches the cassette locally without replaying it — the scripting verb, and the one to reach for when a replay isn’t wanted at all. quiote replay <id> --save is the same operation under the replay command’s own name.

Terminal window
php bin/quiote cassette:prune [--older-than=<duration>] [--keep=<n>] [--dry-run] [--json]

--older-than takes a plain duration (30d, 24h, 90m, 45s); --keep retains only the most recently recorded n. The two compose — a cassette is deleted if it matches either — and if neither is given, --older-than defaults to replay.retention_days (14). --dry-run reports without deleting.

A cassette with no recorded_at (a #[NoRecord] skeleton) is never matched by --older-than, since there’s nothing to compare, but can still be pruned by --keep.

Terminal window
php bin/quiote replay <id> [--live] [--force] [--context=<name>] [--as-test] [--expect-fixed]
[--save] [--key=…] [--date=…] [--hour=…] [--json]

Reconstructs the cassette’s request and dispatches it through the real pipeline, then reports drift. --context picks the context, defaulting to the cassette’s own recorded one, then core.default_context. The command exits non-zero when any diagnostic is an error.

Isolation is the default and needs no configuration. Every ledger-backed subsystem is answered from the cassette’s own recorded effects, nothing is performed, and the replay can run anywhere — which is the point of having recorded the request in the first place.

Each substitution goes through a seam that already existed for it, and every one is undone in a finally — including when the dispatch throws, because leaving a stub installed would make every later request in the same process silently replay-shaped:

SubsystemDuring isolated replay
ClockFrozen at the cassette’s recorded_at, so every now()-dependent branch takes the path it originally took. Nothing records individual clock reads, so there is nothing to match — freezing recovers most of the value a recorded clock would have had.
RandomnessNot substituted. Nothing recorded the values, so any substitute would be inventing input — which is what an isolated replay exists to avoid.
EnvironmentStubbedEnvironmentReader. A variable recorded as unset replays as unset; a variable with no recorded effect throws.
CacheStubbedCache. A recorded get() replays its exact hit/miss state (a stored null is not a miss). An unrecorded read returns the caller’s $default — PSR-16 requires that and forbids throwing — and is reported. An unrecorded write silently succeeds; it cannot affect anything.
Outbound HTTPStubbedHttpTransport. Never opens a socket, never resolves a hostname. A recorded call is rebuilt as a real PSR-7 response; an unrecorded one throws a ClientExceptionInterface.
QueueAssertingQueueDriver, bound under the configured driver’s own class. Nothing is pushed anywhere; every push is captured for pushedJobs()/wasJobPushed().
DatabaseDoctrine’s driver middleware serves recorded rows through the same objects; Propulsion gets a ledger-backed connection installed on every datasource, in both read and write mode. Eloquent and Cycle cannot, and the replay refuses instead.

A miss anywhere raises where the subsystem’s own contract allows it (\PDO and PSR-18 do; PSR-16 does not) rather than fabricating a value, because a fabricated value is how an isolated replay ends up producing a passing test that means nothing. The ledger also refuses a positional match by default: answering a call from the next unconsumed effect of the right kind, carrying a different call’s result, is indistinguishable from a correct answer and is recorded as a miss instead.

What isolation covers, and what it does not

Section titled “What isolation covers, and what it does not”

Isolation is only as complete as the seams that exist. In this release that means:

  • A request whose only external effects are database queries through Doctrine or Propulsion replays cleanly. This is the case the feature is built for.
  • A request that reads the cache replays against a cold cache, and each unrecorded read is reported as an error diagnostic. The response is still the application’s own answer, but a cache-dependent branch may take the other path.
  • A request that makes an outbound HTTP call, or reads an environment variable through Quiote\Support\Environment, cannot be replayed in isolation — the stub throws, because nothing recorded those effects to answer from. Use --live.
  • A request that queries through Eloquent, Cycle, or the raw pdo driver is not isolated from its database. Eloquent and Cycle are refused outright. The raw pdo driver registers no effect source at all, so there is nothing to refuse and nothing to intercept: the replayed queries reach the real database. Treat --live as the honest description of that case and configure it deliberately.

Drift is reported, never smoothed over. Both halves come back as one list of diagnostics:

CodeSeverityMeaning
REPLAY_STATUS_MISMATCHerrorThe status a client would see changed.
REPLAY_BODY_MISMATCHerrorThe response body changed, reported as the two sha256 digests. Downgraded to a warning when the recorded body was truncated, since a matching prefix is the strongest claim that can honestly be made about one.
REPLAY_HEADER_MISSING / REPLAY_HEADER_MISMATCH / REPLAY_HEADER_UNEXPECTEDwarningA header set routinely carries ambient values. Date, Set-Cookie and the correlation-id headers (X-Correlation-Id, X-Request-Id, X-Quiote-Rid) are skipped entirely rather than warned about on every run.
REPLAY_EFFECT_MISSerrorThe code asked the ledger for something the cassette has no counterpart for — it now does something it didn’t do when recorded, and whatever it did next was built on a default rather than on what happened.
REPLAY_EFFECT_UNPLAYEDwarningThe cassette recorded an effect nothing asked for — the code no longer does something it used to.
REPLAY_EFFECT_FUZZYwarningA call was answered from a recorded effect with a different fingerprint. A weaker claim than a match, so it says so.

The three effect diagnostics are the part a live replay structurally cannot produce: its effects go to real collaborators, not through a ledger that could notice one missing.

--live dispatches against whatever the context is really configured with and really re-performs the request’s side effects. It exists for the one thing isolation cannot do — confirm a fix works against real collaborators — and carries the two guards that needs:

  • It refuses unless replay.allow_live is true (default false, everywhere).
  • It refuses anything but a safe method (GET, HEAD, OPTIONS, TRACE) without --force.

Safe, not idempotent. PUT and DELETE are idempotent — doing them twice leaves the same state as doing them once — but that says nothing about whether doing them at all is harmless. Gating on idempotence let a recorded DELETE /accounts/42 replay against a live application and delete account 42, with no prompt.

--as-test writes two files under replay.tests_path (default tests/Replay/, relative to core.app_dir): a copy of the cassette at cassettes/{slug}.qcast, and a thin test that references it —

/** Generated from cassette "CRX2050", recorded 2026-08-19T14:02:11+03:00. Edit freely -- regenerating overwrites this file. */
final class ReplayCRX2050Test extends Quiote\Replay\Testing\ReplayTestCase
{
public function testOrdersUpdateReproducesRecordedResponse(): void
{
$this->replay(__DIR__ . '/cassettes/CRX2050.qcast')
->assertStatus(500)
->assertSee('Undefined array key "shipping"');
}
}

ReplayTestCase extends the same HttpTestCase your other feature tests already use, and replay() returns the same TestResponse — so every assertion you already know (assertJsonEquals, assertHeader, assertHasXPath, …) works unchanged. Assertions are scaffolded from what was actually recorded, and deliberately no further: assertStatus() always, assertJsonEquals() for a JSON body, assertSee() on the exception message for an error cassette, assertHeader('Location', …) for a redirect. A recorded database write or enqueued job is called out as a comment naming the SQL or the job — not as commented-out code calling an assertion helper that doesn’t exist, which would invite uncommenting a line that cannot pass.

Cassette text interpolated into the generated source is neutralised first: an id is adopted from a correlation header and an exception message routinely embeds user input, and a newline, a block-comment terminator or a ?> inside a comment hands the rest of the value to the parser as PHP.

An emitted test replays in isolation, which is what makes it safe to run unattended: a recorded POST or DELETE re-runs on every CI build without re-performing the write, and needs no database and no configuration beyond having the package installed. That is why ReplayTestCase deliberately does not go through the CLI’s ReplayEngine, whose replay.allow_live guard is right for a developer pointing a command at a shared application and wrong for a committed test.

replay.tests_allow_live = true opts a whole suite out, into a live dispatch with real reads and real writes on every run. There is no second gate — the setting is the decision — and it is only safe where the environment is disposable.

--as-test --expect-fixed emits the inverted skeleton instead of asserting the recorded (buggy) response:

public function testOrdersUpdateFixesRecordedBug(): void
{
$response = $this->replay(__DIR__ . '/cassettes/CRX2050.qcast');
// Recorded (buggy) response: status 500 (ErrorException: Undefined array key "shipping").
$this->markTestIncomplete('Fix the recorded bug (status 500 (ErrorException: Undefined array key "shipping")), then replace the line below with assertions describing the fixed behaviour.');
}

Replace the markTestIncomplete() call with real assertions once you’ve fixed the bug, and the test starts pinning the fixed behaviour going forward.

Every key, with its default. All of them live under the replay. prefix, which in XML needs a <settings prefix="replay."> wrapper.

KeyDefaultMeaning
replay.enabledfalseMaster switch. Off means the middleware returns after one enum comparison.
replay.recordneverSampling policy: never, error, rate, header, always.
replay.sample_rate0.0Probability for rate, 0.01.0.
replay.trigger_headerX-Quiote-RecordHeader name for header.
replay.capture_bodytrueCapture the request envelope at all. Off means the cassette cannot be replayed.
replay.capture_sessiontrueCapture the session id/existence snapshot.
replay.max_bytes2097152One 2 MiB pool for the request and response bodies together, plus a second, independent pool of the same size for the effect ledger’s payloads — so instrumenting effects never silently costs a request its body. Truncation cuts on a character boundary, so what is kept stays valid UTF-8.
replay.max_effects2000How many effects are kept (max_bytes bounds how large).
replay.storefileStore alias: file, pdo, azure-blob.
replay.store.pathvar/cassettesThe file store’s directory.
replay.local_pathvar/cassettesWhere a fetched cassette is cached locally.
replay.tests_pathtests/ReplayWhere --as-test writes.
replay.retention_days14Default cassette:prune --older-than window.
replay.redact.headerssee RedactionHeader-name denylist.
replay.redact.paramssee aboveParameter/cookie/column-name denylist.
replay.redact.session_csrf, auth.tokenSession-key denylist.
replay.redact.envsee aboveEnv-var-name substring denylist.
replay.redact.modedropdrop, hash or mask.
replay.redact.hash_salt''Salt for hash mode. Set it if you use that mode.
replay.allow_livefalseGates quiote replay --live.
replay.tests_allow_livefalseMakes emitted tests dispatch live instead of in isolation.
replay.store.pdo.connectionmainreplay-pdo.
replay.store.pdo.tablequiote_cassettesreplay-pdo.
replay.store.azure.*see abovereplay-azure.
replay.index.log_analytics.*see abovereplay-azure.

Stated rather than implied, and each of them a gap in the recorder rather than a designed property:

  • HTTP, cache, queue and environment effects are not recorded during a live request in this release, with the replay consequences described above.
  • meta.quiote_version, source_hash, runtime, trace_id and span_id are always null — there is no runtime-readable framework version constant, and OTel span correlation is not wired. cassette:list therefore offers no --stale filter: staleness is a comparison against source_hash.
  • response.stray_output is always empty: output capture belongs to Quiote\Runtime\Kernel and isn’t reachable from a PSR-15 middleware.
  • session.before and session.after carry the same end-of-request snapshot, and user is never populated.