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(cassetteDirectorydefaults tobase_path('tests/Cassettes')) - registers the same commands as
vendor/bin/http-vcras Artisan commands, prefixed withvcr:(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 theHttpfacade, regardless of call site — to install aHandlerStackcarryingVcrMiddleware. If the application already sets its ownhandler, the bridge pushes onto that stack instead of replacing it (from abooted()callback, so it works regardless of provider registration order) - narrows the default for
VCR_ALLOW_RECORDINGwithapp()->environment(): when the variable isn’t set explicitly, recording is allowed only if the environment islocal/testingand 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 withAPP_ENV=testingand 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
localandtestingenvironments. 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
testingenvironment, if http-vcr’s PHPUnit extension isn’t registered inphpunit.xml. Your test calls theHttpfacade 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
VcrClientand 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. HttpVcrServiceProviderinstalls theglobalOptions()handler once per application boot — which, in tests, means inside eachsetUp(). TheVcrMiddlewareit 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 exposesglobalRequestMiddleware()/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 whyHttp::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 aPendingRequestconfigured 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 isHttp::globalOptions(), added in Laravel 11.