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:
| Event | Model property | Dispatched when |
|---|---|---|
PurchaseRecorded | $purchase | Every purchase result the pipeline receives — repeats and stale ones included, so never fulfil on it. |
PurchaseCompleted | $purchase | A purchase is new, or moved to Completed. |
PurchaseFailed | $purchase | A purchase is new, or moved to Failed or Canceled. |
SubscriptionStarted | $subscription | A new subscription is recorded as Completed. |
SubscriptionRenewed | $subscription | An existing subscription moves back to Completed or its ends_at is extended. |
SubscriptionInGracePeriod | $subscription | Moves to InGracePeriod — billing retry, access kept. |
SubscriptionCanceled | $subscription | Moves to Canceled. |
SubscriptionExpired | $subscription | Moves to Failed. |
PurchaseRefunded | $refund | A refund is new, or its refunded amount changed. |
ChargebackReceived | $refund | A 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 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.