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'); // SubscriptionStarted really fired
$fake->assertSubscriptionRecorded('stripe'); // a subscription result arrived
$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) | SubscriptionStarted really fired (for that provider) — a subscription created active or first activated, not a renewal or a canceled one. Read from Event::fake() too. |
assertSubscriptionRecorded(?$provider) | A subscription result arrived through any of them, whatever it did to the subscription. |
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 the way the real providers do: one id — given or generated — is both providerId() and transactionId(), and occurredAt() is the time you pass or now, so event ordering applies to fake deliveries too:
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 dispute
// One id is both providerId() and transactionId(); occurredAt() is now unless given.
FakeResult::subscription('stripe', 'sub_1', occurredAt: now()->subHour()); // older than the last applied event: changes nothingProvider 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');
});Both Google RTDN builders name the configured purchases.settings.google.package_name (com.example.app when none is set) — an RTDN for another package is only audited.
For Stripe, sign the payload with a test webhook secret exactly as Stripe does. Give a payment_intent.* object an explicit invoice field and an invoice.paid object an explicit payment_intent field — without them the package asks Stripe’s Invoice Payments API, so fake that call or set them to null:
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.