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

The package works with zero host configuration, though no provider can verify anything until its credentials are set. The published config/purchases.php in full:

/*
 | Every on/off switch below is read from the environment as a string, so each is
 | read strictly: 1/true/on/yes are on, 0/false/off/no are off, unset or blank
 | (KEY=) keeps the documented default (for push authentication that is ON —
 | fail-closed), and anything else throws an InvalidConfigurationException naming
 | the key. Every other setting is strict too: blank means not set, so the default
 | applies, while a duration such as the Stripe tolerance must be a whole number
 | and URLs and queue names must be strings.
 */
return [
    'models' => [
        'purchase' => \RoundlyConsulting\Purchases\Models\Purchase::class,
        'purchase-item' => \RoundlyConsulting\Purchases\Models\PurchaseItem::class,
        'purchase-refund' => \RoundlyConsulting\Purchases\Models\PurchaseRefund::class,
        'purchase-notification' => \RoundlyConsulting\Purchases\Models\PurchaseNotification::class,
        'subscription' => \RoundlyConsulting\Purchases\Models\Subscription::class,
        'subscription-item' => \RoundlyConsulting\Purchases\Models\SubscriptionItem::class,
    ],

    'providers' => [
        \RoundlyConsulting\Purchases\Providers\Apple\Apple::class,
        \RoundlyConsulting\Purchases\Providers\Google\Google::class,
        \RoundlyConsulting\Purchases\Providers\Stripe\Stripe::class,
    ],

    // Owner morph key type: "bigint", "uuid" or "ulid" — anything else throws.
    'key_type' => env('PURCHASES_KEY_TYPE', 'bigint'),

    // Log every verified raw payload to purchase_notifications before reducing it.
    'audit' => [
        'enabled' => env('PURCHASES_AUDIT_ENABLED', true),
    ],

    // Persist verified notifications on a queue; the webhook still 204s immediately.
    'queue' => [
        'enabled' => env('PURCHASES_QUEUE_ENABLED', false),
        'connection' => env('PURCHASES_QUEUE_CONNECTION'),
        'queue' => env('PURCHASES_QUEUE_NAME'),
    ],

    'routes' => [
        'enabled' => env('PURCHASES_ROUTES_ENABLED', false),
        'prefix' => env('PURCHASES_ROUTES_PREFIX', 'purchases'),
        'middleware' => ['api'],
    ],

    'settings' => [
        'apple' => [
            // The one App Store app this host accepts notifications and transactions for.
            'bundle_id' => env('PURCHASES_APPLE_BUNDLE_ID'),
            'app_apple_id' => env('PURCHASES_APPLE_APP_APPLE_ID'),

            // Production unless switched on.
            'sandbox' => env('PURCHASES_APPLE_SANDBOX', false),
            'url' => [
                'live' => env('PURCHASES_APPLE_LIVE_URL', 'https://buy.itunes.apple.com'),
                'sandbox' => env('PURCHASES_APPLE_SANDBOX_URL', 'https://sandbox.itunes.apple.com'),
            ],
            'password' => env('PURCHASES_APPLE_PASSWORD'),
            'certificate_clock_skew' => env('PURCHASES_APPLE_CERTIFICATE_CLOCK_SKEW', 60),
            'api' => [
                'key_id' => env('PURCHASES_APPLE_KEY_ID'),
                'issuer_id' => env('PURCHASES_APPLE_ISSUER_ID'),
                'private_key' => env('PURCHASES_APPLE_PRIVATE_KEY'),
                'url' => [
                    'live' => env('PURCHASES_APPLE_API_LIVE_URL', 'https://api.storekit.itunes.apple.com'),
                    'sandbox' => env('PURCHASES_APPLE_API_SANDBOX_URL', 'https://api.storekit-sandbox.itunes.apple.com'),
                ],
            ],
        ],

        'google' => [
            'package_name' => env('PURCHASES_GOOGLE_PACKAGE_NAME'),
            'service_account' => [
                'client_email' => env('PURCHASES_GOOGLE_CLIENT_EMAIL'),
                'private_key' => env('PURCHASES_GOOGLE_PRIVATE_KEY'),
                'token_uri' => env('PURCHASES_GOOGLE_TOKEN_URI', 'https://oauth2.googleapis.com/token'),
            ],
            'base_url' => env('PURCHASES_GOOGLE_BASE_URL', 'https://androidpublisher.googleapis.com'),
            'acknowledge' => env('PURCHASES_GOOGLE_ACKNOWLEDGE', true),

            // Pub/Sub pushes are authenticated before they are read — fail-closed.
            'push' => [
                'authenticate' => env('PURCHASES_GOOGLE_PUSH_AUTHENTICATE', true),
                'audience' => env('PURCHASES_GOOGLE_PUSH_AUDIENCE'),
                'service_account_email' => env('PURCHASES_GOOGLE_PUSH_SERVICE_ACCOUNT'),
                'token' => env('PURCHASES_GOOGLE_PUSH_TOKEN'),
                'jwks_url' => env('PURCHASES_GOOGLE_PUSH_JWKS_URL', 'https://www.googleapis.com/oauth2/v3/certs'),
                'jwks_cache_ttl' => env('PURCHASES_GOOGLE_PUSH_JWKS_CACHE_TTL', 3600),
            ],
        ],

        'stripe' => [
            'secret' => env('PURCHASES_STRIPE_SECRET'),
            'webhook_secret' => env('PURCHASES_STRIPE_WEBHOOK_SECRET'),
            'api_version' => env('PURCHASES_STRIPE_API_VERSION', '2026-05-27.dahlia'),
            'base_url' => env('PURCHASES_STRIPE_BASE_URL', 'https://api.stripe.com/v1'),
            'tolerance' => env('PURCHASES_STRIPE_TOLERANCE', 300),
        ],
    ],
];

Package keys

KeyDefaultPurpose
models.*package modelsEloquent model per record type — swap in your own subclass; any other class throws.
providersApple, Google, StripeRegistered provider classes, resolved by their id(). Must be a list of classes implementing Provider.
key_typebigintOwner morph key type: bigint, uuid or ulid; anything else throws when the migrations run.
audit.enabledtrueLog every verified payload to purchase_notifications.
queue.enabledfalseVerify synchronously, persist on a queue.
queue.connectionnullQueue connection for async recording; unset or blank = the default connection.
queue.queuenullQueue name for async recording; unset or blank = the default queue.
routes.enabledfalseRegister the bundled webhook route.
routes.prefixpurchasesURI prefix of the webhook route.
routes.middleware['api']Middleware applied to the webhook route — a list of names.

Apple — settings.apple

KeyEnvDefault
bundle_idPURCHASES_APPLE_BUNDLE_ID—
app_apple_idPURCHASES_APPLE_APP_APPLE_ID—
sandboxPURCHASES_APPLE_SANDBOXfalse
url.livePURCHASES_APPLE_LIVE_URLbuy.itunes.apple.com
url.sandboxPURCHASES_APPLE_SANDBOX_URLsandbox.itunes.apple.com
passwordPURCHASES_APPLE_PASSWORD—
certificate_clock_skewPURCHASES_APPLE_CERTIFICATE_CLOCK_SKEW60
api.key_idPURCHASES_APPLE_KEY_ID—
api.issuer_idPURCHASES_APPLE_ISSUER_ID—
api.private_keyPURCHASES_APPLE_PRIVATE_KEY—
api.url.livePURCHASES_APPLE_API_LIVE_URLapi.storekit.itunes.apple.com
api.url.sandboxPURCHASES_APPLE_API_SANDBOX_URLapi.storekit-sandbox.itunes.apple.com

bundle_id is required for Apple: every App Store notification — and every transaction the App Store Server API returns — must name it, or it is rejected, and it also signs App Store Server API requests. app_apple_id (App Store Connect → App Information) is required in production, where a notification must carry it. sandbox is false by default — production; set it to true only on a host that receives Sandbox notifications, which also switches verifyReceipt and the App Store Server API to their sandbox hosts. api.* enables the App Store Server API, the modern replacement for verifyReceipt; password is the shared secret for legacy receipts. certificate_clock_skew is the leeway in seconds (0–3600) applied to both ends of every certificate’s validity window — a value outside that range throws InvalidConfigurationException, so a typo can never switch the check off, while a blank value is not set, so 60 applies.

Google — settings.google

KeyEnvDefault
package_namePURCHASES_GOOGLE_PACKAGE_NAME—
service_account.client_emailPURCHASES_GOOGLE_CLIENT_EMAIL—
service_account.private_keyPURCHASES_GOOGLE_PRIVATE_KEY—
service_account.token_uriPURCHASES_GOOGLE_TOKEN_URIoauth2.googleapis.com/token
base_urlPURCHASES_GOOGLE_BASE_URLandroidpublisher.googleapis.com
acknowledgePURCHASES_GOOGLE_ACKNOWLEDGEtrue
push.authenticatePURCHASES_GOOGLE_PUSH_AUTHENTICATEtrue
push.audiencePURCHASES_GOOGLE_PUSH_AUDIENCE—
push.service_account_emailPURCHASES_GOOGLE_PUSH_SERVICE_ACCOUNT—
push.tokenPURCHASES_GOOGLE_PUSH_TOKEN—
push.jwks_urlPURCHASES_GOOGLE_PUSH_JWKS_URLwww.googleapis.com/oauth2/v3/certs
push.jwks_cache_ttlPURCHASES_GOOGLE_PUSH_JWKS_CACHE_TTL3600

acknowledge controls whether verified purchases are acknowledged with Google Play automatically. push.* authenticates real-time developer notifications and is fail-closed — see Google Play. push.jwks_cache_ttl, the cache lifetime of Google’s signing keys in seconds, must be at least 1.

Stripe — settings.stripe

KeyEnvDefault
secretPURCHASES_STRIPE_SECRET—
webhook_secretPURCHASES_STRIPE_WEBHOOK_SECRET—
api_versionPURCHASES_STRIPE_API_VERSION2026-05-27.dahlia
base_urlPURCHASES_STRIPE_BASE_URLapi.stripe.com/v1
tolerancePURCHASES_STRIPE_TOLERANCE300

tolerance is the webhook timestamp tolerance in seconds — at least 1, default 300 — so the replay window can’t be switched off by a typo; api_version is sent as the Stripe-Version header on every REST call. secret is needed for REST reads, including the invoice check behind every payment_intent.* event on current API versions (see Stripe).

Environment

Every credential is env-driven, so you rarely publish the config at all. The config file passes env values through raw, and the package reads them strictly. The on/off switches — PURCHASES_AUDIT_ENABLED, PURCHASES_QUEUE_ENABLED, PURCHASES_ROUTES_ENABLED, PURCHASES_APPLE_SANDBOX, PURCHASES_GOOGLE_ACKNOWLEDGE and PURCHASES_GOOGLE_PUSH_AUTHENTICATE — accept true/false, 1/0, on/off or yes/no; unset or blank (KEY=) keeps the documented default (for push authentication that is on — fail-closed). Anything else, like a PURCHASES_KEY_TYPE outside bigint, uuid and ulid, throws an InvalidConfigurationException naming the key — a typo never quietly becomes the default:

# Apple — your app, App Store Server API (+ legacy verifyReceipt shared secret)
PURCHASES_APPLE_BUNDLE_ID=com.example.app
PURCHASES_APPLE_APP_APPLE_ID=1234567890
PURCHASES_APPLE_SANDBOX=false
PURCHASES_APPLE_KEY_ID=
PURCHASES_APPLE_ISSUER_ID=
PURCHASES_APPLE_PRIVATE_KEY=
PURCHASES_APPLE_PASSWORD=
PURCHASES_APPLE_CERTIFICATE_CLOCK_SKEW=60

# Google Play — service account
PURCHASES_GOOGLE_PACKAGE_NAME=
PURCHASES_GOOGLE_CLIENT_EMAIL=
PURCHASES_GOOGLE_PRIVATE_KEY=
PURCHASES_GOOGLE_ACKNOWLEDGE=true
PURCHASES_GOOGLE_PUSH_AUDIENCE=https://your-app.test/purchases/webhooks/google
PURCHASES_GOOGLE_PUSH_SERVICE_ACCOUNT=[email protected]

# Stripe
PURCHASES_STRIPE_SECRET=
PURCHASES_STRIPE_WEBHOOK_SECRET=
PURCHASES_STRIPE_TOLERANCE=300

# Package behaviour
PURCHASES_KEY_TYPE=bigint
PURCHASES_AUDIT_ENABLED=true
PURCHASES_QUEUE_ENABLED=false
PURCHASES_ROUTES_ENABLED=false
PURCHASES_ROUTES_PREFIX=purchases

Strict settings

The other settings are just as strict. A variable that is not set — left out, or blank such as PURCHASES_STRIPE_TOLERANCE= — takes its default; one you set to the wrong shape throws RoundlyConsulting\Purchases\Exceptions\InvalidConfigurationException naming the key:

  • Durations — PURCHASES_STRIPE_TOLERANCE, PURCHASES_GOOGLE_PUSH_JWKS_CACHE_TTL and PURCHASES_APPLE_CERTIFICATE_CLOCK_SKEW — take a whole number such as 300; five or 300.5 throw instead of becoming 0.
  • URLs, the API version, the route prefix and the queue connection and name must be strings. Leave the queue ones unset or blank for the default connection and queue.
  • purchases.providers and purchases.routes.middleware must be lists, and every provider must implement RoundlyConsulting\Purchases\Providers\Provider.
  • A blank credential (PURCHASES_GOOGLE_PUSH_TOKEN=) is not set, so it reads as not configured; a non-string one throws.

Provider secrets are read only from config and env, are marked #[SensitiveParameter] so they never leak into stack traces, and are never logged. php artisan about reports them only as SET or MISSING, and a broken duration or provider list as INVALID.

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.