Billing Plugin (Stripe via Cashier)
TallCMS Billing — Production Setup
This is the operator runbook for production deployment. The full plugin reference (status matrix, behaviour notes, edge cases) lives in the plugin README — this page focuses on the deployment sequence.
Architecture in one paragraph
The Billing plugin watches Cashier's WebhookReceived event. When a Stripe subscription state changes, the listener (SyncSitePlanFromStripe) refetches the subscription from Stripe, looks up the price ID in SitePlan.metadata, and reassigns the user's tallcms_user_site_plans.site_plan_id to the matching plan. The plan's max_sites quota is then enforced by the multisite plugin's existing SitePlanService — Billing doesn't add new quota logic, it just changes which plan the user is on.
Hard prerequisites
tallcms/multisiteplugin installed and migrated.laravel/cashier ^15.0installed in the host app.- Cashier migrations applied (
stripe_idcolumns onusers,subscriptionsandsubscription_itemstables). Laravel\Cashier\Billabletrait on the host'sApp\Models\User.- Env vars set:
STRIPE_KEY,STRIPE_SECRET,STRIPE_WEBHOOK_SECRET. APP_ENVis nottestingin production. (See "Critical security note" below.)
If any of these are missing, the plugin's BillingGate returns false from all three call sites (service provider boot, Filament register, route middleware) and the plugin silently no-ops.
1. Stripe products + prices
In the Stripe Dashboard (live mode):
- Create one Product per paid plan tier — e.g. "Starter", "Pro", "Business".
- Under each product, create two Prices — one monthly recurring, one yearly recurring.
- Note each price ID (
price_xxx_monthly,price_xxx_yearly).
The Free / default tier doesn't need a Stripe product — it's the implicit fallback when a user has no subscription.
2. Cashier setup
composer require laravel/cashier "^15.0"
php artisan vendor:publish --tag=cashier-config
php artisan vendor:publish --tag=cashier-migrations
php artisan migrate
Add the Billable trait to App\Models\User:
use Laravel\Cashier\Billable;
class User extends Authenticatable
{
use Billable;
// ...
}
3. Environment variables
STRIPE_KEY=pk_live_...
STRIPE_SECRET=sk_live_...
STRIPE_WEBHOOK_SECRET=whsec_...
CASHIER_CURRENCY=usd
CASHIER_CURRENCY_LOCALE=en
APP_ENV=production
Critical security note:
BillingGate::isLicensed()short-circuits totruewhenAPP_ENV=testingso the plugin's own test suite can run without mocking Anystack. Never deploy withAPP_ENV=testing— it would let unlicensed installs reach checkout and portal endpoints.
4. SitePlan metadata
For each Stripe-paid SitePlan in tallcms_site_plans, populate the metadata JSON column with the Stripe price IDs:
$plan = SitePlan::where('slug', 'pro')->first();
$plan->metadata = [
'stripe_product_id' => 'prod_XXXX',
'stripe_price_id_monthly' => 'price_XXXX_monthly',
'stripe_price_id_yearly' => 'price_XXXX_yearly',
'price_cents_monthly' => 2900,
'price_cents_yearly' => 29000,
'features' => ['10 sites', 'Custom domains', 'Priority support'],
];
$plan->save();
The exact key names are fixed conventions defined as Tallcms\Billing\Support\PlanResolver::META_* constants. Plugins are forbidden from shipping a config/ directory, so these are inlined in the source rather than operator-overridable. The keys must match exactly.
5. Stripe webhook
In Stripe Dashboard → Developers → Webhooks → Add endpoint:
- URL:
https://your-domain.com/stripe/webhook(Cashier's default — don't change). - Events (minimum):
customer.subscription.createdcustomer.subscription.updatedcustomer.subscription.deletedinvoice.payment_succeededinvoice.payment_failedcustomer.updated
- Signing secret → copy → set as
STRIPE_WEBHOOK_SECRETin.env.
6. Stripe Customer Portal
In Stripe Dashboard → Settings → Billing → Customer portal:
- Subscription updates: ON.
- Products available for upgrade/downgrade: include only the prices you've recorded in
SitePlan.metadata. Anything exposed in the Portal but unmapped in metadata will silently let a user change their Stripe subscription without theirUserSitePlanupdating — quota mismatch with no error. This is operator discipline; there's no API check. - Cancellation: ON.
- Payment method update: ON.
7. Plugin install + license
- Buy
tallcms/billingfrom Anystack → receive license key. - TallCMS admin → Plugin Manager → upload the
tallcms-billing-X.Y.Z.zipfrom the plugin's GitHub releases. - Plugins → Billing → Activate License with the Anystack key.
- Refresh the admin — the Billing page appears in the navigation.
8. Smoke test (in test mode first)
Switch env vars to Stripe test keys (pk_test_* / sk_test_*) + test webhook signing secret, then:
- Log in as a non-super-admin user with a Free site.
- Billing → click "Subscribe to Pro (monthly)" → Stripe Checkout opens.
- Use card
4242 4242 4242 4242, any future expiry, any CVC, any postcode. - Land on
/admin/billing?checkout=success. - Verify
tallcms_user_site_plans.site_plan_idfor the user matches the Pro plan id. - Click "Manage Subscription" → Stripe Portal opens → cancel the subscription.
- Confirm
customer.subscription.deletedwebhook fires (Stripe Dashboard → Webhooks → recent deliveries) and the user's site plan reverts to the default. - Simulate a failed renewal in Stripe Dashboard → confirm
invoice.payment_failedwebhook arrives, the listener handles it, and the user's plan stays put (past-due grace per the status matrix).
Switch to live keys + live webhook only after the test-mode flow works end-to-end.
Things to monitor in production
- Webhook delivery health — Stripe Dashboard → Webhooks → your endpoint → recent deliveries. Anything stuck retrying means
SyncSitePlanFromStripeis throwing. - Cashier
subscriptionstable drift vs Stripe — if Cashier's view of a subscription disagrees with Stripe's, a webhook was missed. tallcms_user_site_plansmismatches with Stripe — if a user shows an active paid subscription in Stripe but the site_plan_id is still the default, the listener didn't run or threw. Checkstorage/logs/laravel.logforTallcms\Billingentries.- License expiry on
tallcms/billing— Anystack license expiry doesn't disable the plugin (hasEverBeenLicensed()short-circuits to true). To fully deactivate, deactivate the license in the Filament Plugins page. Same model as Pro.
Status matrix (when site plans change)
| Stripe subscription status | UserSitePlan action |
|---|---|
active, trialing |
Assign paid plan |
past_due, incomplete |
Leave unchanged (Stripe is retrying the card) |
canceled, unpaid, incomplete_expired |
Downgrade to default |
Past-due users keep their plan and Portal access during retries — removing quota mid-Smart-Retry would be a worse UX than letting them keep the plan for a few days. Eventual canceled arrives only if all retries fail.
Troubleshooting
Route [filament.admin.pages.billing] not defined
Symptom: 500 error after clicking a plan button. Log shows RouteNotFoundException with the offending route name.
Cause: your Filament panel uses a non-default panel ID (e.g. 'app' from make:filament-panel app, or any multi-panel install). The Billing plugin v1.0.1 and earlier hardcoded 'admin' as the panel ID via the cms's tallcms_panel_route() helper.
Fix: upgrade the plugin to v1.0.2 or later — that release switched all three URL-generation call sites (checkout success, checkout cancel, portal return) to Filament's own Billing::getUrl(), which auto-resolves the panel from where the page was actually registered.
If you can't upgrade immediately, the workaround is to set in .env:
TALLCMS_PANEL_ID=app # or whatever your panel id is
Then php artisan config:clear. This makes the v1.0.1 helper resolve correctly.
Out of scope (v1.0.x)
- Promo codes / coupons
- Stripe Tax / VAT collection
- Stripe Connect for marketplace-style multi-tenancy
- Per-team / per-user billing (current model is one subscription per User)
- Custom invoice details, receipt email customisation
- Public marketing pricing page, billing audit log,
billing:verifycommand, custom-domain feature gate
Reference
- Plugin README — status matrix details, behaviour notes, test bucket organisation.
- Multisite Architecture — how SitePlan / UserSitePlan / SitePlanService work, which the Billing plugin sits on top of.
- Plugin Development — general TallCMS plugin lifecycle.
Comments
No comments yet. Be the first to share your thoughts!