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

Queue, audit log & replay

Background processing

Set PURCHASES_QUEUE_ENABLED=true to verify synchronously but persist on a queue. The webhook still answers 204 immediately; a ProcessProviderNotification job records the result on the configured connection and queue and marks the audit row processed. The synchronous path is the default:

PURCHASES_QUEUE_ENABLED=true
PURCHASES_QUEUE_CONNECTION=redis
PURCHASES_QUEUE_NAME=purchases

With the queue on, handle() returns the PurchaseNotification describing what was queued — or an unsaved placeholder when the audit log is off.

Audit log

With PURCHASES_AUDIT_ENABLED=true (the default), every verified notification is stored in purchase_notifications — provider, type, a signature-verified flag, a snapshot of the normalized result and raw payload, and processed_at — before it is reduced to model state. So is every result passed to Purchases::sync(), with signature_verified false and an origin of host, because the package never verified it (query it with where('payload->origin', 'host')):

use RoundlyConsulting\Purchases\Models\PurchaseNotification;

PurchaseNotification::forProvider('apple')->pending()->get();   // recorded, not yet processed

$notification->provider;             // 'apple'
$notification->type;                 // 'subscription', 'purchase', 'refund' …
$notification->signature_verified;   // bool — false for a sync() row
$notification->origin();             // NotificationOrigin::Provider, or ::Host for a sync() row
$notification->payload;              // Collection — the stored snapshot
$notification->processed_at;         // ?Carbon

Processing each event once

Stores deliver at least once. Recording is idempotent and lifecycle events fire only for a real change, but a repeat still writes a second audit row and a signal fires again. purchases.audit.deduplicate stores each result’s event id on its audit row under a unique index per provider, so of two racing deliveries exactly one stores it:

PURCHASES_AUDIT_DEDUPLICATE=true   # needs PURCHASES_AUDIT_ENABLED=true and the event_id migration
PURCHASES_AUDIT_CLAIM_TIMEOUT=300  # seconds before a redelivery may take over a stuck claim
The event’s audit row ishandle()
new — this delivery inserted itprocesses the event and marks the row processed
processed, or soft-deleted by the hostreturns that row; records nothing, fires nothing
unprocessed, claimed less than claim_timeout seconds agothrows EventInProgressException — the webhook route answers 409, so the store retries later
unprocessed, claim released (that delivery threw) or timed out (it died)takes the event over — one conditional update, so one delivery wins — and processes it
  • With the queue on, the first delivery claims the row and dispatches the job; a repeat gets 409 until the job has processed it, then is skipped.
  • Off by default: it needs the migration that adds event_id and claimed_at (see Installation) and the audit log — audit.enabled off with it on throws InvalidConfigurationException. Planned to default to on in the next major.
  • Only handle() deduplicates. sync() and replay() never do, and Purchases::fake()’s handle() skips the audit log.
  • Stripe sometimes sends two different events for one change; they have different ids and are both processed — recording stays idempotent.
  • PurchaseNotification::eventId() reads the event id — the event_id column when deduplication stored it, else the snapshot’s.

Replay

purchases:replay selects stored notifications and replays each through Purchases::replay() — events included — so you can recover from a downstream failure without waiting for a store to resend anything. One it must not or cannot replay (a provider notification that failed verification, or a snapshot that no longer rebuilds) is reported as skipped and the rest continue; host-synced rows replay:

php artisan purchases:replay                      # every stored notification
php artisan purchases:replay 42                   # a single notification by id
php artisan purchases:replay --provider=stripe    # one provider
php artisan purchases:replay --since=2026-01-01   # recorded on or after a date

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.