maartenpaauw/filament-cashier-billing-provider
Integrate Laravel Cashier Stripe subscriptions into Filament multi-tenant panels. Configure plans in cashier.php and make your Billable model the tenant model; supports recurring subscriptions, trials, promo codes, tax IDs, and metered prices.
1. Install
composer require maartenpaauw/filament-cashier-billing-provider
Check the version compatibility matrix before committing — the package majors track Filament majors (v3 of the package targets Filament 4/5, Laravel 12/13, PHP 8.5, Cashier 16). Pin accordingly in composer.json; a stray composer update that bumps Filament without the matching provider version is the most likely first-day breakage.
2. Make your Filament tenant the Billable model
This is the single most important constraint: the class implementing Billable must be the same class used as your panel's tenant model (typically Team, Company, Organization). If you currently bill the User, you have a migration ahead of you — subscriptions, stripe_id, and payment methods all move to the tenant table before the provider is useful.
3. Define plans in config/cashier.php
'plans' => [
'default' => [
'product_id' => env('CASHIER_STRIPE_SUBSCRIPTION_DEFAULT_PRODUCT_ID'),
'price_id' => env('CASHIER_STRIPE_SUBSCRIPTION_DEFAULT_PRICE_ID'),
'trial_days' => 14,
'allow_promotion_codes' => true,
'collect_tax_ids' => true,
'metered_price' => true,
],
],
The array key becomes the plan type unless you set type explicitly. You can also use a backed enum as the key. Keep the Stripe IDs in .env per environment (test mode vs live mode) and always php artisan config:clear after editing.
4. Wire it into the panel provider
use Maartenpaauw\Filament\Cashier\Stripe\BillingProvider;
public function panel(Panel $panel): Panel
{
return $panel
->tenantBillingProvider(new BillingProvider('default'))
->requiresTenantSubscription();
}
First use case to prove out: a tenant that registers, lands on the panel, is redirected to the billing page, subscribes against the default plan, and gains access. If that loop works with Stripe test keys and the CLI forwarding webhooks, everything else is configuration.
Where to look first: config/cashier.php for plan shape, your AdminPanelProvider for enablement, and vendor/maartenpaauw/filament-cashier-billing-provider/src — the README is thin, and the BillingProvider constructor, the tenant repository, and the RedirectIfUserNotSubscribed middleware are the three files that explain actual behaviour.
Start without enforcement, then gate. Add ->tenantBillingProvider(...) alone on a panel first so tenants can self-serve billing while you verify webhooks and the Stripe dashboard line up. Add ->requiresTenantSubscription() once subscription state is trustworthy — the middleware redirects unsubscribed tenants, and a half-configured Stripe account will lock everyone out of the panel.
Tier plans via multiple entries. Since v2.1.0 you can register several plans; give each a stable, semantic key ('starter', 'pro') and let the key double as the plan type. The panel renders the plan names/options from this array, so this config is effectively your pricing UI — keep the labels user-facing.
Keep types immutable. type defaults to the array key and is what subscription checks resolve against. Renaming a key or changing type after tenants have subscribed silently breaks both the billing page and the middleware, because existing Stripe subscriptions still point at the old product. Treat key names as database values: change the display name, never the key.
Choose one trial strategy. trial_days (a fixed number of days at checkout) and has_generic_trial (Cashier's generic trial, evaluated on the Billable model) are mutually exclusive — pick one, and if you use has_generic_trial remember to implement trialEndsAt() on the tenant model, otherwise the middleware's view of "subscribed" won't match Cashier's.
Layered on purpose. v3.0.0 introduced a layered architecture and a tenant repository (v1.6.1) to remove duplication. That means the BillingProvider is a thin Filament adapter over small, injectable pieces. When you need different behaviour — custom plan resolution, a filtered plan list, extra checkout options — read the layer below the adapter and bind your own implementation in a service provider rather than subclassing the Filament provider and copy-pasting it.
Metered plans need reporting. metered_price => true configures the checkout side, but you still own usage reporting via $tenant->reportUsage(). Wire that into whatever produces the billable event (API calls, seats, jobs) and test it against a Stripe test clock so you can actually see an invoice generate.
Gate beyond the panel. requiresTenantSubscription() only covers the panel. For routes outside Filament, add the shipped RedirectIfUserNotSubscribed middleware to the route group — since v3.0.1 it correctly passes through ordinary Response instances, so it composes with other middleware.
Local development loop:
stripe listen --forward-to localhost:8000/stripe/webhook
php artisan cashier:webhook --event=customer.subscription.updated
Use a Stripe test clock to fast-forward trials and renewal failures instead of waiting 14 days, and reset the tenant's subscription between manual test runs. In tests, fake at the Cashier level ($tenant->subscriptions) rather than hitting Stripe — the provider's own tests use Orchestra Testbench and are worth copying as a pattern.
Multiple panels, multiple postures. A common split: an internal/admin panel with no billing requirements and a customer-facing panel with ->requiresTenantSubscription(). The billing provider is per-panel, so this is one line each — don't force a single panel to be both.
mode=payment. If your product needs credit top-ups, that's a separate Cashier integration, not a plan entry.price_id values between .env files are the #1 cause of "clicked subscribe, nothing happened." Cross-check the price ID and that it belongs to the product ID in the same entry.UPGRADING.md before deploying: multiple prices under one product now count as the same subscription. Plans that intentionally share a product will collide.env() inside config/cashier.php is config-cached. After changing .env in staging or production you must php artisan config:cache again. Locally, config:clear is usually the fix for "the plan list is stale."->value, not the case name, is what ends up in type.allow_promotion_codes and collect_tax_ids do nothing if your Stripe account or the price doesn't permit them; verify in the dashboard rather than debugging the PHP./stripe/webhook and the signature secret matches. A tenant who cancels in the Stripe portal but retains panel access almost always has a webhook problem, not a provider problem.src/ for anything beyond the happy path — plan resolution order, the default plan fallback (added v1.3.0), and how the tenant repository resolves the current tenant. Grepping for config('cashier.plans is the fastest entry point.logger() in RedirectIfUserNotSubscribed and dump $tenant->subscribed(), the resolved plan type, and the product ID it's checking. Nine times out of ten the subscription exists in Stripe but the local subscriptions row is missing its stripe_product value because webhooks never ran.BillingProvider('default') is the fallback plan, not a hard requirement — v1.3.0 made the default key the implicit default. If you name your plans differently, either keep a default entry or pass the intended key explicitly.How can I help you explore Laravel packages today?