Configuration
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
| Key | Default | Purpose |
|---|---|---|
models.* | package models | Eloquent model per record type — swap in your own subclass; any other class throws. |
providers | Apple, Google, Stripe | Registered provider classes, resolved by their id(). Must be a list of classes implementing Provider. |
key_type | bigint | Owner morph key type: bigint, uuid or ulid; anything else throws when the migrations run. |
audit.enabled | true | Log every verified payload to purchase_notifications. |
queue.enabled | false | Verify synchronously, persist on a queue. |
queue.connection | null | Queue connection for async recording; unset or blank = the default connection. |
queue.queue | null | Queue name for async recording; unset or blank = the default queue. |
routes.enabled | false | Register the bundled webhook route. |
routes.prefix | purchases | URI prefix of the webhook route. |
routes.middleware | ['api'] | Middleware applied to the webhook route — a list of names. |
Apple — settings.apple
| Key | Env | Default |
|---|---|---|
bundle_id | PURCHASES_APPLE_BUNDLE_ID | — |
app_apple_id | PURCHASES_APPLE_APP_APPLE_ID | — |
sandbox | PURCHASES_APPLE_SANDBOX | false |
url.live | PURCHASES_APPLE_LIVE_URL | buy.itunes.apple.com |
url.sandbox | PURCHASES_APPLE_SANDBOX_URL | sandbox.itunes.apple.com |
password | PURCHASES_APPLE_PASSWORD | — |
certificate_clock_skew | PURCHASES_APPLE_CERTIFICATE_CLOCK_SKEW | 60 |
api.key_id | PURCHASES_APPLE_KEY_ID | — |
api.issuer_id | PURCHASES_APPLE_ISSUER_ID | — |
api.private_key | PURCHASES_APPLE_PRIVATE_KEY | — |
api.url.live | PURCHASES_APPLE_API_LIVE_URL | api.storekit.itunes.apple.com |
api.url.sandbox | PURCHASES_APPLE_API_SANDBOX_URL | api.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
| Key | Env | Default |
|---|---|---|
package_name | PURCHASES_GOOGLE_PACKAGE_NAME | — |
service_account.client_email | PURCHASES_GOOGLE_CLIENT_EMAIL | — |
service_account.private_key | PURCHASES_GOOGLE_PRIVATE_KEY | — |
service_account.token_uri | PURCHASES_GOOGLE_TOKEN_URI | oauth2.googleapis.com/token |
base_url | PURCHASES_GOOGLE_BASE_URL | androidpublisher.googleapis.com |
acknowledge | PURCHASES_GOOGLE_ACKNOWLEDGE | true |
push.authenticate | PURCHASES_GOOGLE_PUSH_AUTHENTICATE | true |
push.audience | PURCHASES_GOOGLE_PUSH_AUDIENCE | — |
push.service_account_email | PURCHASES_GOOGLE_PUSH_SERVICE_ACCOUNT | — |
push.token | PURCHASES_GOOGLE_PUSH_TOKEN | — |
push.jwks_url | PURCHASES_GOOGLE_PUSH_JWKS_URL | www.googleapis.com/oauth2/v3/certs |
push.jwks_cache_ttl | PURCHASES_GOOGLE_PUSH_JWKS_CACHE_TTL | 3600 |
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
| Key | Env | Default |
|---|---|---|
secret | PURCHASES_STRIPE_SECRET | — |
webhook_secret | PURCHASES_STRIPE_WEBHOOK_SECRET | — |
api_version | PURCHASES_STRIPE_API_VERSION | 2026-05-27.dahlia |
base_url | PURCHASES_STRIPE_BASE_URL | api.stripe.com/v1 |
tolerance | PURCHASES_STRIPE_TOLERANCE | 300 |
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=purchasesStrict 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 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.