mtk3d / laravel-http-vcr
Record and replay HTTP requests in Laravel tests — every Http:: facade call goes through a cassette, with nothing to wire up. The Laravel bridge for mtk3d/http-vcr.
Requires
- php: ^8.2
- guzzlehttp/guzzle: ^7.0 || ^8.0
- illuminate/console: ^12.0 || ^13.0
- illuminate/http: ^12.0 || ^13.0
- illuminate/support: ^12.0 || ^13.0
- mtk3d/http-vcr: ^0.1.1
Requires (Dev)
- larastan/larastan: ^3.0
- laravel/pint: ^1.30
- orchestra/testbench: ^10.0 || ^11.0
- phpstan/extension-installer: ^1.4
- phpstan/phpstan: ^2.0
This package is auto-updated.
Last update: 2026-08-31 17:02:31 UTC
README
Every Http:: call in a test with #[UseCassette] is recorded once and replayed on every
run after that, with nothing to wire up.
This is the Laravel bridge for mtk3d/http-vcr. The
recording and replaying lives there; this package is the auto-wiring that puts it behind
Laravel's Http facade.
#[UseCassette('shopify/get-product')] public function testGetProduct(): void { $product = Http::get('https://shop.myshopify.com/admin/api/products/123.json')->json(); $this->assertSame('T-Shirt', $product['title']); }
No Http::fake(), no fixture to write by hand, no client to construct. The first run makes
the real request and writes tests/Cassettes/shopify/get-product.yaml; every run after
replays it — no network, no API key, no flakiness.
Installation
composer require --dev mtk3d/laravel-http-vcr
The service provider registers itself. One line in phpunit.xml is still needed, because
PHPUnit has no auto-discovery for extensions:
<extensions> <bootstrap class="HttpVcr\Bridge\PHPUnit\Extension"/> </extensions>
That is the whole setup. Without it #[UseCassette] does nothing and every call reaches the
real API — so the package checks for it while booting in the testing environment and says
so once per run rather than letting it pass unnoticed.
Requires Laravel 12 or newer and PHP 8.2+ (Laravel 13 needs PHP 8.3).
What it does
- Intercepts the
Httpfacade application-wide throughIlluminate\Http\Client\Factory::globalMiddleware(), so every call is covered no matter which method on the facade made it, and no matter where in the application it came from. Middleware the application registers itself is appended to, never replaced. - Registers the CLI as Artisan commands:
vcr:stale,vcr:providers,vcr:tests,vcr:scan-secrets,vcr:migrate,vcr:lock,vcr:unlock— the same commands asvendor/bin/http-vcr, prefixed because Artisan is one flat namespace shared with the framework and everything else installed. - Narrows when recording is allowed. http-vcr already refuses to record on CI. Here that
is narrowed further with the application environment: with
VCR_ALLOW_RECORDINGunset, recording is allowed only inlocalortestingand where no CI was detected. The bridge only ever tightens the default — an environment check on its own would be worse than useless, since tests on CI run withAPP_ENV=testing. A value set explicitly still wins over both. - Stays out of the way outside
localandtesting. The commands register anywhere; the HTTP hook does not.
A test with no #[UseCassette] behaves exactly as if this package were not installed —
including Http::fake(), which is untouched.
Configuration
There is nothing Laravel-specific to configure, and no config file to publish. http-vcr
reads an optional http-vcr.php in the project root, and its defaults already resolve to
base_path('tests/Cassettes') and base_path('tests') in a Laravel application:
<?php use HttpVcr\Config; use HttpVcr\Provider; return Config::create( providers: [ 'shopify' => new Provider(hosts: ['*.myshopify.com'], requiresEnv: ['SHOPIFY_API_KEY']), ], redact: ['<SHOPIFY_API_KEY>' => fn () => env('SHOPIFY_API_KEY')], );
The root of the project rather than config/: the file returns a Config object carrying
closures, and php artisan config:cache serialises every file in config/ with
var_export() — a closure there turns a cached config into a fatal. Every option is in the
configuration reference.
Assertions on a replayed call
Http::assertSent() works, with one line:
#[UseCassette('shopify/get-product')] public function testItAsksForTheRightProduct(): void { Http::record(); (new Shopify)->product('123'); Http::assertSent(fn (Request $request) => str_contains($request->url(), '/products/123')); }
Http::record() is Laravel's own switch for the recorder behind those assertions. It is
normally turned on as a side effect of Http::fake(), which a cassette replaces — so with
cassettes it is asked for directly. The replayed call is reported to that recorder on
purpose: assertSent() asks what the application did, and a replayed call is something the
application did.
Http::preventStrayRequests()
A cassette does not fight it, and it does not have to be turned off:
#[UseCassette('shopify/get-product', mode: RecordMode::PlaybackOnly)] public function testGetProduct(): void { Http::preventStrayRequests(); // in your base TestCase, most likely $product = (new Shopify)->product('123'); // replayed, not stray }
Laravel enforces the guard in its stub handler, which is pushed onto the stack after global middleware and therefore sits inside this package's — so a replayed call is answered above it and never reaches it.
The run that records is a different matter. It goes down the whole stack, so it does
reach the guard, and with no stub registered Laravel refuses it before it can reach the API.
That is the guard working as intended, but it means the first run of a new cassette needs
Http::allowStrayRequests() — narrow it to the host you are recording, and the guard keeps
covering everything else:
Http::allowStrayRequests(['https://shop.myshopify.com/*']);
The cassette format
YAML wherever symfony/yaml is installed, JSON otherwise — a Laravel application usually
has it, so cassettes are usually YAML. To pin one either way, name the serializer in
http-vcr.php; php artisan vcr:migrate rewrites existing recordings into the other
format.
Everything else
Record modes, matchers, redaction, strict mode, staleAfter, cassette scoping, the CLI and
the cassette format are the core library's, and they behave here exactly as they do
anywhere else:
Not using this package
Http:: goes through Guzzle, so the middleware bridge works without any of this — at the
cost of wiring the handler yourself in each test or in a testing-only service provider. The
recipe is on the Laravel page
of the book.
Development
composer install
vendor/bin/phpunit
vendor/bin/phpstan analyse --memory-limit=512M # level max
vendor/bin/pint --test
CI runs PHPUnit across PHP 8.2–8.4 and Laravel 12–13, plus a leg on the lowest versions every constraint allows; PHPStan and Pint run once each, since neither answer depends on the framework release.
License
MIT — see LICENSE.