---
title: "Testing — Purchases for Laravel | Roundly"
description: "Test purchase flows without real payments or network calls — Purchases::fake() records handle(), sync() and replay(); FakeResult and PayloadFactory."
url: https://roundly-consulting.com/open-source/docs/purchases-for-laravel/testing
language: en
---

[All packages](https://roundly-consulting.com/open-source.md)

[Purchases for Laravel](https://roundly-consulting.com/open-source/docs/purchases-for-laravel.md)

# Testing

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:

```php
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:

```php
$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:

```php
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 nothing
```

## Provider 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():

```php
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:

```php
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 crypto](https://roundly-consulting.com/support-us.md)

By donating, you agree to our [donation terms](https://roundly-consulting.com/donation-terms.md).

[Support our open source work (opens in a new tab)](https://donate.stripe.com/dRmeVe8FX5PF1Qd9pXcEw00) [Join us on Patreon (opens in a new tab)](https://www.patreon.com/cw/roundly)

## 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.

[Get a quote in 48 hours](https://roundly-consulting.com/contact.md) [Browse all packages](https://roundly-consulting.com/open-source/docs/purchases-for-laravel.md)
