---
title: "The Purchases facade — Purchases for Laravel | Roundly"
description: "Every Purchases facade method — result() and handle() for requests, sync() and replay() for held results, for($owner) and provider lookups."
url: https://roundly-consulting.com/open-source/docs/purchases-for-laravel/purchases-facade
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)

# The Purchases facade

RoundlyConsulting\\Purchases\\Facades\\Purchases is the recommended entry point. result() verifies and decodes a request into a provider-agnostic ProviderResult without writing anything; handle() does the same, then records the model and dispatches the lifecycle events. sync() and replay() record results that didn’t arrive as a webhook, and for($owner) scopes queries to one owner:

```php
use RoundlyConsulting\Purchases\Facades\Purchases;

$result = Purchases::result('stripe', $request);   // verify + decode, no writes
$result->type();        // ResultType::Subscription
$result->status();      // Status enum
$result->providerId();  // provider-side id

$model = Purchases::handle('stripe', $request);     // verify + decode + persist + events
$model = Purchases::sync($result);                  // persist a result you already hold (?Model)
$model = Purchases::replay($notification);          // re-run one audited notification (?Model)

Purchases::for($user)->subscribedTo('pro');        // one owner's purchases and subscriptions

Purchases::provider('google');   // Provider (throws UnknownProviderException if absent)
Purchases::providers();          // Collection<string, Provider>
Purchases::has('apple');         // bool
Purchases::ids();                // ['apple', 'google', 'stripe']
```

## Method reference

| Method | Returns | What it does |
| --- | --- | --- |
| `result($id, $request)` | `ProviderResult` | Verify and decode a request — no writes. |
| `handle($id, $request)` | `Model` | Verify, decode, persist and dispatch events; honours queue.enabled. |
| `sync(ProviderResult $result)` | `?Model` | Persist a result you already hold, audited like a webhook, always synchronously. |
| `replay(PurchaseNotification\|int $notification)` | `?Model` | Re-run one stored audit notification and mark it processed. |
| `for(Model $owner)` | `OwnerPurchases` | One owner’s purchases and subscriptions — read-only queries. |
| `provider($id)` | `Provider` | Resolve a provider; UnknownProviderException if it isn’t configured. |
| `providers()` | `Collection<string, Provider>` | Every provider, keyed by id. |
| `has($id)` | `bool` | Whether a provider is registered. |
| `ids()` | `list<string>` | The registered provider ids. |
| `fake()` | `PurchasesFake` | Swap in the recording spy — see Testing. |

## What handle() returns

- Always — the request is verified synchronously first; an invalid signature throws VerificationException before anything is written.
- Queue off (the default) — the persisted Purchase, Subscription or PurchaseRefund.
- Queue on — the PurchaseNotification audit row describing what was queued, or an unsaved placeholder when the audit log is off.
- Informational result (an Apple renewal-preference change, a Google deferral, a TEST, an unmapped Stripe event …) — nothing is recorded, no event fires, and the processed audit notification is returned.

## Persisting a result you already hold

sync() records a ProviderResult exactly the way a webhook is recorded — audited, reduced to a Purchase, Subscription or PurchaseRefund, events fired, audit row marked processed — and always synchronously. Use it for a receipt your app verified itself, or a GenericResult built for a backfill. It re-verifies nothing, so never pass it an unverified client payload; the audit row says so truthfully, with signature\_verified false and origin NotificationOrigin::Host:

```php
use RoundlyConsulting\Purchases\Enum\ResultType;
use RoundlyConsulting\Purchases\Enum\Status;
use RoundlyConsulting\Purchases\Facades\Purchases;
use RoundlyConsulting\Purchases\Results\GenericResult;

// A Google Play purchase token your app sent up, checked against the Play Developer API
// (product() throws VerificationException unless the purchase is in a purchased state).
$purchase = Purchases::provider('google')->product($productId, $token);

$model = Purchases::sync(new GenericResult(   // ?Model — null for an informational result
    provider: 'google',
    type: ResultType::Purchase,
    providerId: $purchase->orderId ?? $token,  // the key Google's own notifications use
    status: Status::Completed,
    transactionId: $purchase->orderId,
    name: $productId,
    productId: $productId,
));
```

If recording throws (a listener fails), the audit row stays pending, so replay() can finish it later.

## Replaying one notification

replay() rebuilds the result from a stored PurchaseNotification, records it again and marks it processed. Recording is ordered by event time (see Events), so replaying a notification — or the whole log — never moves a purchase or subscription backwards and never fires a lifecycle event for something already applied. It replays a verified provider notification or a host-origin row written by sync(); a provider-origin row that failed verification, a soft-deleted or unsaved notification, or a snapshot that no longer rebuilds throws InvalidProviderNotificationException, and an unknown id throws ModelNotFoundException:

```php
Purchases::replay($notification);   // ?Model — the PurchaseNotification model …
Purchases::replay(42);              // … or its id
```

## One owner’s purchases

for($owner) returns an OwnerPurchases handle scoped to exactly that owner — its morph type and key — so a same-id owner of another model never leaks in, and an unsaved owner sees nothing. The owner model needs no trait:

```php
use RoundlyConsulting\Purchases\Facades\Purchases;

Purchases::for($user)->subscribedTo('pro');            // bool
Purchases::for($user)->activeSubscription('pro');      // ?Subscription (latest active)
Purchases::for($user)->purchases()->latest()->get();   // Builder<Purchase>
Purchases::for($user)->subscriptions()->count();       // Builder<Subscription>
```

| Method | Returns | Description |
| --- | --- | --- |
| `purchases()` | `Builder<Purchase>` | The owner’s purchases, ready to chain. |
| `subscriptions()` | `Builder<Subscription>` | The owner’s subscriptions, ready to chain. |
| `activeSubscription(?string $name = null)` | `?Subscription` | The latest active subscription, optionally by plan name. |
| `subscribedTo(string $name)` | `bool` | Whether the owner has an active subscription to that plan. |

Owners are yours to set. A store notification never says which of your users it belongs to, so every row recorded by a webhook, handle(), sync() or replay() starts with no owner — for($user) and HasPurchases see it only once you associate it, typically in a listener (see Events).

## Plan names

subscribedTo($name) and activeSubscription($name) match Subscription::$name. Apple and Google name a subscription after its product id (com.example.pro) on every notification, so an upgrade renames it. Stripe names no plan: a new Stripe subscription is named after its price’s product (prod\_…) and never renamed, so a name you give it — pro, say — sticks through every later event.

## Using it in your own route

The bundled webhook route is optional. To keep full control of the endpoint, call handle() from your own route and map a failed verification to a 400 — exactly what the bundled controller does. Stores post without a CSRF token, so keep the route outside CSRF protection:

```php
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Route;
use RoundlyConsulting\Purchases\Exceptions\VerificationException;
use RoundlyConsulting\Purchases\Facades\Purchases;

Route::post('billing/stripe/webhook', function (Request $request) {
    try {
        Purchases::handle('stripe', $request);
    } catch (VerificationException) {
        abort(400);
    }

    return response()->noContent();
});
```

## Resolving providers

```php
use RoundlyConsulting\Purchases\Facades\Purchases;

if (Purchases::has('google')) {
    $google = Purchases::provider('google');   // RoundlyConsulting\Purchases\Providers\Google\Google
}

foreach (Purchases::providers() as $id => $provider) {
    // 'apple' => Apple, 'google' => Google, 'stripe' => Stripe
}
```

Providers are resolved through the container and keyed by their id() — apple, google and stripe for the built-ins. provider() throws UnknownProviderException for an id that isn’t registered.

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