Purchases::fake() swaps the manager — for the facade and for anything that injects PurchasesManager (the webhook controller, purchases:replay, your own services) — with a PurchasesFake spy and returns it. It records every handle(), sync() and replay() and still runs the real recording pipeline, so rows are written and your listeners fire (add Event::fake() if you don’t want them to). Results queued with push() skip signature verification; a faked handle() also skips the audit log and the queue, while sync() and replay() run exactly as in production:
use Illuminate\Http\Request;
use RoundlyConsulting\Purchases\Facades\Purchases;
use RoundlyConsulting\Purchases\Models\Subscription;
use RoundlyConsulting\Purchases\Testing\FakeResult;
it('starts a subscription from a Stripe webhook', function () {
$fake = Purchases::fake();
$fake->push('stripe', FakeResult::subscription('stripe', 'sub_1'));
$model = Purchases::handle('stripe', Request::create('/', 'POST'));
Purchases::sync(FakeResult::purchase('apple', 'txn_1'));
expect($model)->toBeInstanceOf(Subscription::class);
$fake->assertHandled('stripe');
$fake->assertHandledCount(1);
$fake->assertSubscriptionStarted('stripe');
$fake->assertSynced('apple');
$fake->assertPurchaseRecorded('apple');
});Assertions
| Method | Passes when / returns |
|---|---|
push($provider, $result) | Queue a result the next result() / handle() for that provider returns — no signature check. |
handledResults() / syncedResults() | Collection of every handled / synced result, for custom assertions. |
assertHandled($provider) | handle() saw a result for that provider. |
assertHandledCount($count) | handle() saw exactly $count results. |
assertNothingHandled() | handle() was never called. |
assertSynced(?$provider) | sync() received a result (for that provider). |
assertNothingSynced() | sync() was never called. |
assertReplayed(?$notification) | Anything was replayed — or that notification (model or id). |
assertNothingReplayed() | Nothing was replayed. |
assertPurchaseRecorded(?$provider) | A purchase arrived through handle(), sync() or replay(). |
assertSubscriptionStarted(?$provider) | A subscription result arrived through any of them. |
assertRefundRecorded(?$provider) | A refund or chargeback arrived through any of them. |
Replays are recorded too, including those the command runs:
$fake = Purchases::fake();
$this->artisan('purchases:replay', ['id' => $notification->id]); // the command injects PurchasesManager
$fake->assertReplayed($notification); // or its id; assertReplayed() alone = anything replayed
$fake->assertNothingHandled();
$fake->assertNothingSynced();A refused replay records nothing. The HasPurchases helpers are read-only, so they record nothing either — but they delegate to the manager and therefore resolve the fake.
FakeResult
FakeResult builds ready-made GenericResults with sensible defaults:
use RoundlyConsulting\Purchases\Enum\Status;
use RoundlyConsulting\Purchases\Testing\FakeResult;
FakeResult::purchase('stripe', 'pi_1'); // Completed, 9.99 USD
FakeResult::purchase('apple', status: Status::Failed);
FakeResult::subscription('google', 'sub_1', Status::InGracePeriod, 'pro'); // 19.99 USD, ends in a month
FakeResult::refund('stripe', 're_1'); // reason requested_by_customer
FakeResult::refund('stripe', 'dp_1', chargeback: true); // a disputeProvider payloads
PayloadFactory builds raw, store-shaped payloads — appleNotification(), googleEnvelope(), googleSubscriptionNotification(), googleVoidedNotification() and stripeEvent() — to drive the real providers end to end. A Google notification decodes offline once push authentication is off; recording a subscription RTDN also reads subscriptionsv2, so fake that call with Http::fake():
use Illuminate\Http\Request;
use RoundlyConsulting\Purchases\Providers\Google\Google;
use RoundlyConsulting\Purchases\Testing\PayloadFactory;
it('decodes a Google renewal notification', function () {
// Proving the push came from Google is not this test's job.
config()->set('purchases.settings.google.push.authenticate', false);
$request = new Request(PayloadFactory::googleEnvelope(
PayloadFactory::googleSubscriptionNotification(type: 2, token: 'token-1'), // 2 = renewed
));
$notification = app(Google::class)->notification($request);
expect($notification->subscriptionNotification?->purchaseToken)->toBe('token-1');
});For Stripe, sign the payload with a test webhook secret exactly as Stripe does. Give a payment_intent.* object an explicit invoice field — without one the package asks Stripe’s Invoice Payments API which invoice it pays:
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Event;
use RoundlyConsulting\Purchases\Events\PurchaseCompleted;
use RoundlyConsulting\Purchases\Facades\Purchases;
use RoundlyConsulting\Purchases\Testing\PayloadFactory;
it('records a signed Stripe payment', function () {
Event::fake([PurchaseCompleted::class]);
config()->set('purchases.settings.stripe.webhook_secret', 'whsec_test');
$payload = (string) json_encode(PayloadFactory::stripeEvent('payment_intent.succeeded', [
'id' => 'pi_1', 'status' => 'succeeded', 'amount' => 1000, 'currency' => 'usd',
'invoice' => null, // pays no invoice — no Invoice Payments lookup needed
]));
$timestamp = now()->getTimestamp();
$signature = hash_hmac('sha256', "{$timestamp}.{$payload}", 'whsec_test');
$request = Request::create('/', 'POST', content: $payload);
$request->headers->set('Stripe-Signature', "t={$timestamp},v1={$signature}");
$purchase = Purchases::handle('stripe', $request);
expect($purchase->price->minor())->toBe('1000');
Event::assertDispatched(PurchaseCompleted::class);
});Run your suite with the published migrations loaded (RefreshDatabase).
Show your open-source love
This package is free and MIT-licensed. If it saves you time, a one-off donation or a Patreon membership keeps it maintained, tested and documented.
More ways to support, including cryptoBy donating, you agree to our donation terms.
Want this built into your product?
We integrate our packages into custom Laravel and AI builds. Tell us what you're working on and we'll reply within 48 hours.