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 subscription starts: created as Completed, or Completed for the first time after it was only ever New or Pending (created before it was paid for). |
SubscriptionRenewed | $subscription | A subscription that was active before moves back to Completed (from on hold, grace period or canceled, or a paused Google subscription resuming), or its ends_at is extended. |
SubscriptionInGracePeriod | $subscription | Moves to InGracePeriod — billing retry, access kept. |
SubscriptionOnHold | $subscription | Moves to OnHold — no access, not over: Stripe unpaid or paused (and past_due under settings.stripe.past_due = on_hold), Apple’s billing retry after its grace period, Google’s account hold. $subscription->meta keeps the store’s own status. |
SubscriptionResumed | $subscription | An on-hold subscription is active again — paid, or resumed. SubscriptionRenewed fires with it. |
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. |
SubscriptionTrialWillEnd | $subscription | Signal — Stripe customer.subscription.trial_will_end, three days before a trial ends (or at once when it is ended early or has fewer days left). The subscription is recorded first; check its status before asking for payment. |
PaymentActionRequired | ?$subscription | Signal — Stripe invoice.payment_action_required: a payment waits for the customer (3-D Secure, a bank redirect). Carries $owner and $actionUrl, the invoice’s hosted page. Records nothing. |
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
});On hold, resumed and signals
SubscriptionOnHold and SubscriptionResumed fire for every provider. Stripe’s past_due is in grace by default; set purchases.settings.stripe.past_due to on_hold to take access away at once. SubscriptionTrialWillEnd and PaymentActionRequired are signals — they say something happened, not that a status moved — so a repeated delivery cannot be told from the first and they fire for every delivery. Turn on purchases.audit.deduplicate to process each event once. replay() fires a signal only for a notification that was never processed, and a soft-deleted subscription fires none:
use Illuminate\Support\Facades\Event;
use RoundlyConsulting\Purchases\Events\PaymentActionRequired;
use RoundlyConsulting\Purchases\Events\SubscriptionOnHold;
use RoundlyConsulting\Purchases\Events\SubscriptionResumed;
use RoundlyConsulting\Purchases\Events\SubscriptionTrialWillEnd;
Event::listen(SubscriptionOnHold::class, function (SubscriptionOnHold $event): void {
// no access until it is paid or resumed; $event->subscription->meta['status'] keeps the store's own status
});
Event::listen(SubscriptionResumed::class, function (SubscriptionResumed $event): void {
// access back — SubscriptionRenewed fires with it
});
Event::listen(SubscriptionTrialWillEnd::class, function (SubscriptionTrialWillEnd $event): void {
// check $event->subscription->status first: payment may already be in
});
Event::listen(PaymentActionRequired::class, function (PaymentActionRequired $event): void {
// 3-D Secure or a bank redirect — send $event->owner to $event->actionUrl (the hosted invoice page)
});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();
}
});SubscriptionStarted also fires when a subscription created before it was paid for — Stripe incomplete (3D Secure, payment_behavior=default_incomplete) or Google’s pending state — first becomes active, so this listener runs for those subscriptions too. A paused Google subscription that resumes fires SubscriptionRenewed.
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.