php-vcr/vcr-bundle
Symfony integration for PHP-VCR that records real HTTP interactions and replays them in your test suite. Configure cassettes, hooks and fixtures to keep functional tests fast, deterministic and offline without changing your HTTP client code.
Install (dev-only):
composer require --dev php-vcr/vcr-bundle:^3.0
This pulls in php-vcr/vcr (^1.6) plus a Symfony bundle (VCRVCRBundle) and a web-profiler data collector. Register it only for dev/test — there is no need for it in prod, and you do not want cassette paths or curl hooks leaking into production boot:
// config/bundles.php
return [
// ...
VCR\VCRBundle\VCRVCRBundle::class => ['dev' => true, 'test' => true],
];
Requirements as of 3.0 (up from 2.x): PHP ^8.1 (CI covers 8.3/8.4, with 8.5 behind continue-on-error until GA), Symfony ^5.4|^6.4, PHPUnit ^9.6|^10.5 (dev). If you are on PHP 7.x or Symfony 4, stay on the 2.x line.
Where to look first, in order:
DependencyInjection/Configuration.php in the bundle — this is the authoritative config tree (cassette.path, storage, mode, whitelist, etc.). The README is thin; the config tree is short and readable.Test/VCRTestCaseTrait.php (or wherever VCRTestCaseTrait lives in your tag) — new to 3.0's test tooling; it wraps insert/eject around each test. Read it before hand-rolling your own base case, because it already solves the cassette-leak problem described below.php-vcr/vcr README — the bundle is a thin Symfony wrapper, so all the real concepts (cassettes, modes, request matchers, library hooks) live upstream.DataCollector/ — tells you exactly what the profiler panel exposes, which is your main debugging tool.Minimal config (verify key names against Configuration.php for your installed version):
# config/packages/test/vcr.yaml
vcr:
enabled: true
cassette:
path: '%kernel.project_dir%/tests/Fixtures/cassettes'
storage: json # or yaml
mode: once # once | new_episodes | none | all
First use case — record a real HTTP call once, then replay it forever:
use VCR\VCR;
public function testGithubApi(): void
{
VCR::insertCassette('github_user_octocat');
$response = (new \GuzzleHttp\Client())->get('https://api.github.com/users/octocat');
$this->assertSame(200, $response->getStatusCode());
$this->assertSame('octocat', json_decode($response->getBody(), true)['login']);
VCR::eject();
}
Run it once with network access, and a cassette file appears under tests/Fixtures/cassettes/. Delete the file and re-run to re-record. That record/replay loop is the package.
Prefer the bundled VCRTestCaseTrait over a hand-rolled base case. 3.0 updated it for PHPUnit 10, where getName() was removed — it now uses name() / dataName() with a backwards-compatible fallback for PHPUnit 9.6. That means one trait covers both supported PHPUnit majors, and cassette names follow the data-provider-aware test name rather than just the method:
use VCR\VCRBundle\Test\VCRTestCaseTrait;
final class GithubApiTest extends KernelTestCase
{
use VCRTestCaseTrait;
public function testOctocat(): void { /* cassette handled for you */ }
}
Check the trait source for its cassette path/name convention before adopting it — if it doesn't match your fixture layout, subclass or copy it rather than fighting it.
If you need custom naming, wrap insert/eject in a base TestCase. VCR is a static, process-global facade — it is not a Symfony service you inject. Almost every flaky-VCR bug comes from a cassette surviving into the next test:
abstract class VcrTestCase extends KernelTestCase
{
protected function setUp(): void
{
parent::setUp();
VCR::insertCassette(str_replace('\\', '_', static::class) . '_' . $this->name());
}
protected function tearDown(): void
{
VCR::eject();
parent::tearDown();
}
}
Drive mode from the environment, not from code. The four modes map cleanly onto a workflow:
| Mode | Use |
|---|---|
once |
Local dev default — record if the cassette is missing, replay otherwise |
none |
CI — never touch the network; missing cassette = red build |
new_episodes |
Add a new interaction to an existing cassette without wiping it |
all |
Deliberate re-recording session, then commit the diff |
Ship mode: none in config/packages/test/vcr.yaml and override to once locally via .env.test.local, or vice versa — pick one and be consistent.
Tighten request matching deliberately. The default matchers (method, host, path, query, body) compare a lot, so cassettes break on any header/query churn. For APIs where the URL is the identity, trim to what matters:
VCR::configure()->addRequestMatcher(
'path_and_method',
fn(Request $a, Request $b) => $a->getMethod() === $b->getMethod()
&& $a->getUrl() === $b->getUrl()
);
Add this in a bootstrap file wired through phpunit.xml (<php><file>bootstrap.php</file></php>), not inside individual tests — VCR::configure() is once-per-process.
Scrub credentials before the cassette hits disk. Auth headers, ?api_key=, session cookies and Date headers are what make cassettes unreviewable and leaky. Filter on the way in (a whitelist of hosts plus a RequestMatcher-adjacent normaliser in a bootstrap, or a thin custom Storage decorator) rather than post-editing JSON.
Use it for functional tests of real controllers. It pairs well with KernelBrowser: boot the kernel, make the request, and let CurlHook/StreamWrapperHook intercept the outbound call to a payment gateway or OAuth provider. The MockHttpClient covers your code; VCR covers the third-party protocol — they are different jobs and both are worth having.
Profiler workflow. In dev with framework.profiler on, the VCR panel shows the cassette name, recording mode, and each request/response. When a test fails with "no matching request", open the panel in a dev-environment reproduction — it's far faster than diffing the cassette JSON by hand.
composer require php-vcr/vcr-bundle:^3.0 and nothing else. Existing cassettes stay valid — no re-record required.^5.4|^6.4. If you're planning a Symfony 7 migration, VCR is one more thing that will hold you back, so plan the bundle upgrade separately from the framework upgrade.php-vcr/php-vcr to ^1.6 even if you pin the bundle yourself. 1.5.x validates the curl handle with is_resource(), which always returns false on PHP 8+ where curl_init() returns a CurlHandle object. Recording silently fails on modern PHP with 1.5 — this is the one dependency footgun the release explicitly closes.vcr: config once at boot; every VCR::insertCassette() afterwards is a global side effect. Don't interleave cassettes in one test, and don't rely on the container to reset anything.vcr service to inject. You call the VCR facade directly; the bundle's job is wiring VCR::configure() from YAML and registering the profiler collector.ext-curl assumptions. Recording works because php-vcr hooks curl_exec (and the stream wrapper). Clients built on curl_multi — Guzzle's default handler, Symfony's CurlHttpClient under concurrency — can produce truncated or missing recordings. If cassettes come out empty, switch to a single-request curl handler or Symfony's NativeHttpClient before blaming the bundle.curl_multi). Check that before assuming the bundle is broken.Authorization: Bearer … and Set-Cookie. Add a pre-commit check or scrub step; the package doesn't do it for you.storage: yaml, and normalise volatile headers so re-records don't produce noise.name()/dataName(), but any of your own helpers calling TestCase::getName() will break on PHPUnit 10 independently of VCR. If you pinned ^9.5 in composer.json, you can now widen to ^9.6|^10.5.0 dependents, ~13 stars) whose releases track Symfony majors rather than shipping features. Read the composer.json constraints for the tag you install; last-release dates are not a proxy for maintenance. 3.0 is a deliberate LTS-alignment bump, so expect long gaps between releases rather than a stream of new knobs.VCR\Storage\Storage implementations, extra request matchers, and the whitelist are all php-vcr/vcr concepts surfaced through bundle config. If a knob isn't in Configuration.php, drop to a PHP bootstrap and call VCR::configure() — but do it after kernel boot, or the bundle's boot-time configuration will overwrite yours.How can I help you explore Laravel packages today?