NewWe open-sourced 50+ Laravel packages
Custom AI apps, agents and automation — Roundly ConsultingRoundly
All packages
Purchases for Laravel

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:

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

MethodReturnsWhat it does
result($id, $request)ProviderResultVerify and decode a request — no writes.
handle($id, $request)ModelVerify, decode, persist and dispatch events; honours queue.enabled.
sync(ProviderResult $result)?ModelPersist a result you already hold, audited like a webhook, always synchronously.
replay(PurchaseNotification|int $notification)?ModelRe-run one stored audit notification and mark it processed.
for(Model $owner)OwnerPurchasesOne owner’s purchases and subscriptions — read-only queries.
provider($id)ProviderResolve a provider; UnknownProviderException if it isn’t configured.
providers()Collection<string, Provider>Every provider, keyed by id.
has($id)boolWhether a provider is registered.
ids()list<string>The registered provider ids.
fake()PurchasesFakeSwap 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:

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:

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:

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>
MethodReturnsDescription
purchases()Builder<Purchase>The owner’s purchases, ready to chain.
subscriptions()Builder<Subscription>The owner’s subscriptions, ready to chain.
activeSubscription(?string $name = null)?SubscriptionThe latest active subscription, optionally by plan name.
subscribedTo(string $name)boolWhether 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:

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

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

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