Billing (Stripe, Paddle)
This kit implements a provider-agnostic billing domain with adapter implementations per provider.
This kit implements a provider-agnostic billing domain with adapter implementations per provider.
1) Overview
- Billing is attached to the User
- Provider webhooks are the source of truth
- Feature access is determined by Entitlements, not plan-name checks
- Provider-specific logic lives in adapters, not core domain code
2) Canonical data model
Canonical tables (do not leak provider-specific logic into your app):
billing_customersproducts,pricessubscriptionsorders(one-time purchases)invoiceswebhook_events(idempotency and audit)discounts,discount_redemptions
Admin Panel resources:
- Products (plans), Prices
- Subscriptions, Orders, Invoices, Customers, Webhook Events
- Discounts
3) Adapter responsibilities
Each provider adapter should:
- create checkout sessions
- verify webhook signatures
- map provider IDs to canonical records
- handle cancellations, refunds, and pauses
- apply discounts/coupons during checkout (if supported)
Note: Ensure the API keys are configured for each provider.
3.1 Production Setup
On production environments, you must manually seed the payment providers once to enable them in the Admin Panel:
php artisan db:seed --class=PaymentProviderSeeder --force
3.2 Supported providers (where to configure)
Supported billing providers are code-defined, not free-form database entries.
- Enum list:
app/Enums/BillingProvider.php - Adapter/runtime wiring:
app/Domain/Billing/Services/BillingProviderManager.php - Admin page lets you add only these supported providers.
4) Configuration
4.1 config/saas.php
Central config should include enabled providers and catalog behavior.
Example shape:
return [
'billing' => [
'providers' => ['stripe', 'paddle'],
'default_provider' => env('BILLING_DEFAULT_PROVIDER', 'stripe'),
'default_plan' => env('BILLING_DEFAULT_PLAN', 'starter'),
'catalog' => env('BILLING_CATALOG', 'database'),
'success_url' => env('BILLING_SUCCESS_URL'),
'cancel_url' => env('BILLING_CANCEL_URL'),
'pricing' => [
// product keys shown on /pricing
'shown_plans' => ['hobbyist', 'indie', 'agency'],
'provider_choice_enabled' => env('BILLING_PROVIDER_CHOICE_ENABLED', true),
],
],
'support' => [
'email' => env('SUPPORT_EMAIL'),
'discord' => env('SUPPORT_DISCORD_URL'),
],
];
Notes:
shown_planscontains product keys (products.key).- In domain terms, a "plan" is a Product record plus one or more related Prices.
4.2 Catalog source
The active catalog is database-backed by default:
BILLING_CATALOG=database(default, use Admin Panel resources)BILLING_CATALOG=config(legacy/static setups)
When using the database catalog:
- Run migrations.
- Create
ProductsandPricesin the Admin Panel (Product= plan definition). - Ensure every product/plan has at least one active price per provider.
- Provider IDs can be left blank until you publish the catalog.
- Publish the catalog to providers to generate/link provider IDs:
php artisan billing:publish-catalog stripe --apply --updatephp artisan billing:publish-catalog paddle --apply --update
Discount providers are controlled via saas.billing.discounts.providers.
5) Environment variables (template)
Keep provider secrets in .env only. Never store secrets in DB.
5.0 Billing plan IDs
Billing price IDs are keyed per provider and plan:
BILLING_DEFAULT_PROVIDER=stripe
BILLING_CATALOG=config
BILLING_SUCCESS_URL=
BILLING_CANCEL_URL=
BILLING_STARTER_MONTHLY_STRIPE_ID=
BILLING_STARTER_MONTHLY_PADDLE_ID=
BILLING_STARTER_YEARLY_STRIPE_ID=
BILLING_STARTER_YEARLY_PADDLE_ID=
Add the BILLING_GROWTH_* and BILLING_LIFETIME_* IDs from .env.example for subscription and one-time plans.
When using the database catalog, provider IDs live on price_provider_mappings.provider_id (linked from prices), not .env.
5.1 Stripe
Typical keys:
STRIPE_KEYSTRIPE_SECRETSTRIPE_WEBHOOK_SECRET
5.2 Paddle
Typical keys:
PADDLE_VENDOR_IDPADDLE_API_KEYPADDLE_WEBHOOK_SECRET
Exact keys depend on the adapter package you use. Keep them documented here.
6) Checkout flows
6.1 Subscription checkout
Requirements:
- user selects plan/price (monthly/yearly)
- checkout session is created for the user
- session metadata includes:
user_id,plan_key,price_key, andquantity
6.2 One-time purchase checkout
Requirements:
- same metadata patterns
- canonical
ordersrecord is created/updated on webhook confirmation
6.3 Post-checkout redirect
Redirect does not confirm payment. Show a processing screen that waits for webhook confirmation.
7) Webhook handling
7.1 Endpoints
/webhooks/stripe/webhooks/paddle
7.2 Mandatory behavior
- verify signature
- persist event to
webhook_events(statusreceived) - enqueue a job for processing
- process idempotently:
- unique constraint on
(provider, event_id) - safe to retry jobs
- unique constraint on
7.3 Failed events
- mark event
failed - store error message
- provide Admin Panel action "retry"
8) Entitlements
Entitlements are computed from canonical billing state and plan definitions (products + prices). Do not branch on plan names.
9) Discounts & coupons
- Manage coupons in the Admin Panel (
discountstable). - Redemptions are recorded on webhook confirmation (
discount_redemptions). - Coupons are supported for Stripe and Paddle checkout flows.
Required fields for a Stripe coupon:
provider = stripeprovider_type = coupon(orpromotion_code)provider_id = Stripe coupon or promo code ID
10) Testing billing
Minimum tests:
- webhook idempotency (same event twice)
- subscription activation via webhook
- cancellation/resume flows
- order paid/refunded flows
- coupon redemption recorded on checkout
11) Catalog import (Stripe)
If you prefer to create products/prices in Stripe first, you can import them into the DB catalog.
Preview only:
php artisan billing:import-catalog stripe
Apply changes:
php artisan billing:import-catalog stripe --apply
To overwrite existing records (not recommended unless you want Stripe to drive copy):
php artisan billing:import-catalog stripe --apply --update
Notes:
- The import never deletes records.
- Stripe products become Products (plans), Stripe prices become Prices.
- For stable keys, set Stripe product metadata
plan_keyand optionalproduct_key.
12.1 Catalog publish (app-first)
If you prefer to create products/prices in the Admin Panel (or via Seeder) first, you can publish them to the providers. This creates the products on the provider side and saves the resulting IDs to your database, linking them.
Crucial: You must run this command to avoid "Price not configured" errors.
Preview:
php artisan billing:publish-catalog stripe
php artisan billing:publish-catalog paddle
Apply changes:
php artisan billing:publish-catalog stripe --apply --update
php artisan billing:publish-catalog paddle --apply --update
Notes:
- Creates products/prices on the provider.
- Links existing records if keys match.
12.2 Production Update Workflow
When you need to add or change products/prices (plans/prices) on production, follow this sequence to ensure everything stays in sync:
- Update Code: Modify
BillingProductSeeder.php(or use Admin Panel on local). - Deploy: Push your changes to production.
- Seed (Optional): Run the seeder to update your local database values.
php artisan db:seed --class=PaymentProviderSeeder --force php artisan db:seed --class=BillingProductSeeder --force - Publish (CRITICAL): Push the changes to your providers to generate/link IDs.
php artisan billing:publish-catalog stripe --apply --update php artisan billing:publish-catalog paddle --apply --update
13) Troubleshooting
- If checkout redirect succeeds but subscription stays inactive:
- verify webhook endpoint is reachable from the provider
- verify signature secret
- check
webhook_eventslog in Admin Panel
14) Staging / Production Readiness Check
Run the built-in checklist command before go-live:
php artisan billing:check-readiness
Use strict mode in CI to fail on warnings too:
php artisan billing:check-readiness --strict
What it validates:
APP_URLand webhook URL shape- queue mode for webhook processing
- failed-job persistence configuration
- active provider secrets (
Stripe/Paddle) - route availability for
/webhooks/{provider}
Recommended release gate:
php artisan migrate --forcephp artisan db:seed --class=PaymentProviderSeeder --forcephp artisan billing:publish-catalog stripe --apply --updatephp artisan billing:publish-catalog paddle --apply --updatephp artisan billing:check-readiness --strict- Confirm queue worker(s) are running and consuming jobs
- Send one Stripe + one Paddle test webhook and confirm both are marked
processed
15) Archive Products (Provider Cleanup)
Use the billing:archive-all command to archive (soft-delete) products directly on billing provider dashboards. Archived products won't sync into the local database.
Usage
# Preview what would be archived (no changes made)
php artisan billing:archive-all --provider=stripe --dry-run
# Archive all Stripe products
php artisan billing:archive-all --provider=stripe
# Archive all Paddle products and prices
php artisan billing:archive-all --provider=paddle
# Archive across all providers
php artisan billing:archive-all --provider=all
# Include prices (Stripe only)
php artisan billing:archive-all --provider=stripe --include-prices
Provider behavior
| Provider | Action |
|---|---|
| Stripe | Sets active: false on products (and optionally prices) |
| Paddle | Sets status: archived on products and prices |
When to use
- Development cleanup - Clear out test products
- Provider migration - Archive old provider before switching
- Fresh start - Clean slate for your product catalog