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

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.