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 Core billing keys
BILLING_DEFAULT_PROVIDER=stripe
BILLING_CATALOG=database
BILLING_SUCCESS_URL=
BILLING_CANCEL_URL=
When using the default database catalog, provider price IDs are stored in
price_provider_mappings.provider_id (linked from prices), not .env.
If you run a custom legacy config catalog, document those keys in your own config file.
5.1 Stripe
Typical keys:
STRIPE_KEYSTRIPE_SECRETSTRIPE_WEBHOOK_SECRET
5.2 Paddle
Typical keys:
PADDLE_VENDOR_IDPADDLE_API_KEYPADDLE_CLIENT_SIDE_TOKEN(recommended)PADDLE_WEBHOOK_SECRET
Exact keys depend on the adapter package you use. Keep them documented here.
5.3 Secret requirements by flow
- Stripe checkout/catalog actions require:
STRIPE_SECRET - Stripe webhook verification requires:
STRIPE_WEBHOOK_SECRET - Stripe hosted checkout page UX uses:
STRIPE_KEY(recommended) - Paddle checkout/catalog actions require:
PADDLE_API_KEY - Paddle inline checkout page requires:
PADDLE_VENDOR_ID - Paddle webhook verification requires:
PADDLE_WEBHOOK_SECRET - Paddle client token (
PADDLE_CLIENT_SIDE_TOKEN) is recommended and validated by readiness checks as a warning if missing
If any required key is missing, provider actions should fail with a clear configuration error, not an SDK/type error.
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
12.3 Staging Subscription Test Catalog
If your current catalog is one-time heavy and you want to validate subscription flows end-to-end on staging, use:
php artisan billing:seed-subscription-plans --force
This command:
- upserts three recurring products (using the first three
saas.billing.pricing.shown_planskeys when available) - creates/updates
monthlyandyearlyrecurring prices - deactivates one-time prices (
once/one_time) for those seeded products
To immediately sync provider IDs in staging:
php artisan billing:seed-subscription-plans --publish --force
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 shapeAPP_KEYpresence- queue mode for webhook processing
- failed-job persistence configuration
- active provider secrets (
Stripe/Paddle, includingPADDLE_VENDOR_ID) - 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 - Run the full release checklist in
docs/billing-go-live-checklist.md
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
16) Error handling and DX
16.1 Runtime behavior for missing secrets
- Billing runtime adapters throw
BillingException::missingConfiguration(...)for missing required keys. - Checkout and portal controllers catch provider failures and show user-safe messages.
- Invoice download falls back cleanly when provider secrets are missing (redirects back to billing with an error).
- Social auth callback/redirect now catches provider misconfiguration errors and returns to login with a clear message.
16.2 Diagnostics flow for developers
- Run
php artisan billing:check-readinesslocally/staging. - Fix every
FAILresult first (especially missing provider secrets). - For production pipelines, use
php artisan billing:check-readiness --strict. - Verify one real webhook per provider reaches
processedstate.
16.3 Common misconfiguration symptoms
... is not configuredfrom billing services: A required provider key is missing; check.env, config cache, and active provider settings.- Checkout page loads but provider widget fails:
Most often missing/invalid
PADDLE_VENDOR_IDor provider environment mismatch. - Social login redirects back to
/loginwith a social error: Check OAuth client id/secret/redirect URI inconfig/services.phpvalues.
17) Pay What You Want (PWYW)
The kit includes first-class support for "pay what you want" pricing — ideal for open-source projects, community-funded SaaS, or gratitude-based pricing.
17.1 How it works
Any one-time price can be configured as a custom-amount price. The customer enters their own amount during checkout, subject to optional minimum/maximum constraints.
17.2 Setting up a PWYW price
In the Admin Panel (Products > Prices), create a one-time price with these fields:
| Field | Value | Purpose |
|---|---|---|
allow_custom_amount |
true |
Enables the custom amount input |
custom_amount_minimum |
e.g. 500 (= $5.00) |
Minimum payment (minor units) |
custom_amount_maximum |
e.g. 100000 (= $1,000.00) |
Maximum payment (optional) |
custom_amount_default |
e.g. 2000 (= $20.00) |
Pre-filled default amount |
suggested_amounts |
[500, 1000, 2000, 5000] |
Quick-select buttons shown on checkout |
All amounts are in minor currency units (cents for USD). So 500 = $5.00.
17.3 Checkout UX
When a customer selects a PWYW price:
- The pricing page shows a "Pay what you want" badge with the starting amount
- The checkout page shows a large amount input with currency prefix
- Quick-select suggested amount buttons let users pick common tiers
- The order summary updates in real-time as the amount changes
- Minimum/maximum constraints are enforced client-side and server-side
17.4 Use case: Open-source dogfooding
For public open-source projects where you want to accept voluntary contributions:
- Create a product (e.g. "Community Supporter" or "Sponsor")
- Add a one-time price with
allow_custom_amount = true - Set a low minimum (e.g. $1) or no minimum
- Add suggested amounts like
[$5, $10, $25, $50, $100] - Add the product key to
saas.billing.pricing.shown_plans
Users see a friendly "Support this project" checkout experience with quick contribution buttons.
17.5 Technical details
- Custom amounts are passed as
custom_amountin the checkout form - The
CheckoutServicevalidates and passes the amount to the Stripe adapter - Stripe creates an ad-hoc price with
unit_amountset to the customer's choice - Orders are recorded with the actual paid amount
- Upgrade credits work with PWYW (previous one-time payment is credited)