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

Purchases::handle(), sync(), replay(), the queued job and purchases:replay all dispatch events from RoundlyConsulting\Purchases\Events. Each carries the persisted model plus the originating ProviderResult as $event->result:

EventModel propertyDispatched when
PurchaseRecorded$purchaseEvery purchase result the pipeline receives — repeats and stale ones included, so never fulfil on it.
PurchaseCompleted$purchaseA purchase is new, or moved to Completed.
PurchaseFailed$purchaseA purchase is new, or moved to Failed or Canceled.
SubscriptionStarted$subscriptionA new subscription is recorded as Completed.
SubscriptionRenewed$subscriptionAn existing subscription moves back to Completed or its ends_at is extended.
SubscriptionInGracePeriod$subscriptionMoves to InGracePeriod — billing retry, access kept.
SubscriptionCanceled$subscriptionMoves to Canceled.
SubscriptionExpired$subscriptionMoves to Failed.
PurchaseRefunded$refundA refund is new, or its refunded amount changed.
ChargebackReceived$refundA chargeback (dispute) is new, or its amount changed.

Every store may deliver a notification more than once and out of order, and the audit log can be replayed, so the lifecycle events fire only when something changed — a new row, a status that moved, a renewal that extended ends_at, a new refunded amount. Recording is ordered by event time (see Recording pipeline), so a redelivered or replayed payment never re-fulfils a refunded order. PurchaseRecorded fires for every purchase result the pipeline receives — repeats and stale ones included — so never fulfil on it; use PurchaseCompleted. Informational notifications are audited but fire nothing.

Listening

use Illuminate\Support\Facades\Event;
use RoundlyConsulting\Purchases\Events\PurchaseRefunded;
use RoundlyConsulting\Purchases\Events\SubscriptionExpired;
use RoundlyConsulting\Purchases\Events\SubscriptionStarted;

Event::listen(SubscriptionStarted::class, function (SubscriptionStarted $event): void {
    $event->subscription->owner;   // grant access
    $event->result->provider();    // 'apple', 'google' or 'stripe'
});

Event::listen(SubscriptionExpired::class, function (SubscriptionExpired $event): void {
    // revoke access
});

Event::listen(PurchaseRefunded::class, function (PurchaseRefunded $event): void {
    $event->refund->purchase;      // the refunded Purchase, when matched
});

Linking the owner

Recording never sets the owner — the owner morph is nullable, because a store notification never says which of your users it belongs to. Associate it in a listener from the account token you passed to the store — Apple’s appAccountToken, Google’s obfuscatedExternalAccountId, the Stripe customer:

use App\Models\User;
use Illuminate\Support\Facades\Event;
use RoundlyConsulting\Purchases\Events\SubscriptionStarted;

Event::listen(function (SubscriptionStarted $event): void {
    $token = $event->result->raw()['data']['transactionInfo']['appAccountToken'] ?? null;   // Apple

    if ($user = User::query()->where('app_account_token', $token)->first()) {
        $event->subscription->owner()->associate($user)->save();
    }
});

Revoking access and host recipes

A full refund flips the matched purchase to Status::Refunded (a partial one leaves it Completed), so revoking access can hang off PurchaseRefunded and ChargebackReceived. The same events are the integration point for granting or clawing back credits, starting post-purchase campaigns or feeding a metrics sink — wired in your app, so the package stays free of those dependencies.

Purchases can also back a shops-for-laravel payment gateway. Shops charges through its PaymentGateway contract, bound with shops.payment.gateway. Purchases never creates a charge — it reads and verifies what the store already took — so a gateway in your app can confirm the PaymentIntent the buyer paid at checkout through the Stripe provider, and issue refunds with its own call. Neither package depends on the other:

namespace App\Payments;

use App\Models\CheckoutPayment;
use Illuminate\Support\Facades\Http;
use RoundlyConsulting\Money\Money;
use RoundlyConsulting\Purchases\Providers\Stripe\Enums\PaymentIntentStatus;
use RoundlyConsulting\Purchases\Providers\Stripe\Stripe;
use RoundlyConsulting\Shops\Contracts\PaymentGateway;
use RoundlyConsulting\Shops\Orders\Order;
use RoundlyConsulting\Shops\Payments\PaymentResult;

final class StripeIntentGateway implements PaymentGateway
{
    public function __construct(private Stripe $stripe) {}

    // Confirm the PaymentIntent the buyer paid at checkout — purchases reads it from Stripe.
    public function charge(Order $order): PaymentResult
    {
        $due = $order->gatewayAmount();
        $intent = $this->stripe->paymentIntent($this->intentId($order));

        return $intent->status === PaymentIntentStatus::Succeeded && $intent->amount?->equals($due)
            ? PaymentResult::success($due, $intent->id)
            : PaymentResult::failure($due, 'Payment not confirmed.');
    }

    // purchases never writes to a store: refund with your own call. Stripe's
    // charge.refunded webhook then lands in purchases as a PurchaseRefund.
    public function refund(Order $order, Money $amount): PaymentResult
    {
        $response = Http::withToken(config('purchases.settings.stripe.secret'))->asForm()
            ->post('https://api.stripe.com/v1/refunds', [
                'payment_intent' => $this->intentId($order),
                'amount' => $amount->minorInt(),
            ]);

        return $response->successful()
            ? PaymentResult::success($amount, $response->json('id'))
            : PaymentResult::failure($amount, $response->json('error.message'));
    }

    private function intentId(Order $order): string
    {
        // Your own record of the PaymentIntent confirmed at checkout.
        return CheckoutPayment::query()->where('order_id', $order->getKey())->value('payment_intent');
    }
}

// config/shops.php — or SHOPS_PAYMENT_GATEWAY in .env
'payment' => ['gateway' => \App\Payments\StripeIntentGateway::class],

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.