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.

Concurrent first deliveries

The recording transaction runs up to three attempts. On MySQL, locking a key that doesn’t exist yet takes a gap lock, so two first deliveries of the same purchase, subscription or refund can deadlock (error 1213) on their inserts; the loser is retried and finds the winner’s row instead of failing its webhook or queued job.

Dates and the app timezone

Every store reports its dates in UTC, while Eloquent stores the wall-clock time of the instance it is handed. So every provider date — active_from, trial_ends_at, ends_at, refunded_at and the event time — is moved into the application timezone before it is stored: on a host whose app.timezone isn’t UTC, a subscription still expires at its real instant.

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.