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

Recording pipeline

handle(), sync(), replay() and the queued job share one recording core. It routes a result by type() — refunds become a PurchaseRefund, purchases a Purchase, subscriptions a Subscription — and dispatches the matching events. A Notification or Unknown result is informational: recorded nowhere, it fires nothing and can never overwrite a subscription’s status. What differs is the audit row around it:

EntryAudit rowRecordingReturns
handle()Written first (origin provider, signature verified)Now, or on the queue when queue.enabledThe record — or the audit notification when queued or informational
sync()Written first (origin host, signature_verified false)Always synchronousThe record, or null for an informational result
replay()None written — the stored row is marked processedSynchronous, ordered by event timeThe record, or null for an informational notification

Idempotent by design

Every record is keyed on the unique (provider, provider_id) pair, so a store that redelivers a notification updates the same row instead of duplicating it. Line items are re-synced whenever the result carries any; a null price leaves the stored price untouched; the raw payload is kept in the model’s meta column. A new subscription without a name from the store is named after the product id, then the provider id; a later event without a name leaves the stored one alone.

Ordered by event time

Recording is ordered by occurredAt(). Each purchase, subscription and refund stores the time of the latest event applied to it (last_event_at): an older event — a redelivery, an out-of-order delivery, a replay — changes nothing and fires nothing, and a refunded row stays refunded unless an event provably newer than the refund reverses it (Apple’s REFUND_REVERSED, a won Stripe dispute). A result that cannot say when it happened (occurredAt() null — a GenericResult you built without one) is applied as before, but never un-refunds anything. The row is locked while this is decided, so two deliveries racing each other cannot interleave. A row you soft-deleted is still kept up to date by later notifications (it stays deleted), but fires no lifecycle event.

Recording a result you obtained yourself

Use Purchases::sync() — or its action, SyncProviderResultAction — for a result that didn’t arrive as a webhook; see The Purchases facade and DI and actions. The Record* actions behind the core are @internal — they skip the audit row that sync() writes and marks processed.

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.