Key takeaways
- ✓ Filament tenancy has a billing hook:
->tenantBillingProvider()plus->requiresTenantSubscription(). You provide the integration. - ✓ Laravel Cashier on your tenant model (
Team) is the natural fit, since the team pays, not the individual user. - ✓ Let Stripe host the sensitive parts: Checkout to subscribe and the Customer Portal to manage. Your panel just routes users to them.
- ✓ Webhooks are the source of truth. Exclude
stripe/*from Laravel 13’spreventRequestForgerymiddleware and runphp artisan cashier:webhook.
Every SaaS product built on Filament eventually asks the same question: how do teams pay? Filament’s tenancy includes a billing integration point. It ships a provider for Laravel Spark, and you can write your own in about 50 lines. This guide builds the Cashier-based version: teams subscribe through Stripe Checkout, manage billing in Stripe’s Customer Portal, and lose access to the panel when their subscription lapses. Internal staff get a separate admin view of subscriptions and MRR.
Also Read: Filament vs Building Your Own Admin Panel: When Is It Worth It?
It assumes you already have a tenant-aware panel. If not, start with building a multi-tenant SaaS panel in Filament v5.
Architecture in one picture
- Customer panel (
/app/{team}): tenants use your product. The billing menu item goes to your billing route. - Billing route: not subscribed → plan picker → Stripe Checkout. Subscribed → Stripe Customer Portal.
- Subscription middleware: blocks the panel for unsubscribed teams and redirects them to billing.
- Stripe webhooks → Cashier: keeps the
subscriptionstable in sync. - Admin panel (
/admin): staff-only. A read-only Subscriptions resource and an MRR widget.
Step 1: Install Cashier on the tenant model
composer require laravel/cashier
php artisan vendor:publish --tag="cashier-migrations"Cashier’s migrations assume the billable model is User. Edit them before migrating:
- In the customer-columns migration, change
Schema::table('users', ...)toSchema::table('teams', ...). - In the subscriptions migration, change the
user_idforeign key toteam_id.
php artisan migrateThen make Team billable and tell Cashier:
// app/Models/Team.php
use Laravel\Cashier\Billable;
class Team extends Model
{
use Billable;
}// app/Providers/AppServiceProvider.php
use App\Models\Team;
use Laravel\Cashier\Cashier;
public function boot(): void
{
Cashier::useCustomerModel(Team::class);
}Add your keys to .env (STRIPE_KEY, STRIPE_SECRET, STRIPE_WEBHOOK_SECRET) and set CASHIER_CURRENCY=gbp if you don’t bill in dollars. Cashier 16.x (current: 16.8, September 2026) supports Laravel 10 through 13.
Step 2: Describe your plans in config
Keep Stripe price IDs out of your code:
// config/billing.php
return [
'plans' => [
'starter' => [
'name' => 'Starter',
'price_id' => env('STRIPE_PRICE_STARTER'),
'amount' => 1900, // pence, for display only
'features' => ['3 users', '1,000 contacts'],
],
'pro' => [
'name' => 'Pro',
'price_id' => env('STRIPE_PRICE_PRO'),
'amount' => 4900,
'features' => ['Unlimited users', 'API access', 'Priority support'],
],
],
];Step 3: Write a billing provider
Filament’s contract is small. A provider returns the route action for the billing page and the middleware that enforces a subscription:
// app/Billing/CashierBillingProvider.php
namespace App\Billing;
use App\Http\Controllers\BillingController;
use App\Http\Middleware\RedirectIfTeamNotSubscribed;
use Filament\Billing\Providers\Contracts\BillingProvider;
class CashierBillingProvider implements BillingProvider
{
public function getRouteAction(): string
{
return BillingController::class;
}
public function getSubscribedMiddleware(): string
{
return RedirectIfTeamNotSubscribed::class;
}
}Register it on the customer panel:
->tenant(Team::class, slugAttribute: 'slug')
->tenantBillingProvider(new CashierBillingProvider())
->requiresTenantSubscription()requiresTenantSubscription() applies your middleware to the whole panel. To gate only some resources or pages, leave it off the panel and override isTenantSubscriptionRequired(Panel $panel): bool on those classes instead.
Also Read: Laravel: Best Filament Themes
Step 4: The billing controller
// app/Http/Controllers/BillingController.php
namespace App\Http\Controllers;
use App\Filament\Pages\ChoosePlan;
use Filament\Facades\Filament;
class BillingController
{
public function __invoke()
{
/** @var \App\Models\Team $team */
$team = Filament::getTenant();
abort_unless(auth()->user()->can('manageBilling', $team), 403);
if ($team->subscribed('default')) {
// Stripe-hosted portal: change plan, update card, cancel, invoices
return $team->redirectToBillingPortal(Filament::getUrl($team));
}
return redirect(ChoosePlan::getUrl(tenant: $team));
}
}Only team owners should reach billing, so put that rule in TeamPolicy::manageBilling().
Step 5: A plan picker page with Checkout actions
// app/Filament/Pages/ChoosePlan.php
namespace App\Filament\Pages;
use Filament\Actions\Action;
use Filament\Facades\Filament;
use Filament\Pages\Page;
class ChoosePlan extends Page
{
protected string $view = 'filament.pages.choose-plan';
protected static bool $shouldRegisterNavigation = false;
public static function isTenantSubscriptionRequired(\Filament\Panel $panel): bool
{
return false; // must stay reachable without a subscription
}
public function getPlans(): array
{
return config('billing.plans');
}
public function subscribeAction(): Action
{
return Action::make('subscribe')
->label(fn (array $arguments) => 'Choose ' . config("billing.plans.{$arguments['plan']}.name"))
->action(function (array $arguments) {
$plan = config("billing.plans.{$arguments['plan']}")
?? abort(404);
$team = Filament::getTenant();
$checkout = $team->newSubscription('default', $plan['price_id'])
->allowPromotionCodes()
->checkout([
'success_url' => Filament::getUrl($team) . '?checkout=success',
'cancel_url' => static::getUrl(tenant: $team),
]);
$this->redirect($checkout->asStripeCheckoutSession()->url);
});
}
}{{-- resources/views/filament/pages/choose-plan.blade.php --}}
<x-filament-panels::page>
<div >
@foreach ($this->getPlans() as $key => $plan)
<x-filament::section :heading="$plan['name']">
<p >£{{ number_format($plan['amount'] / 100) }}<span >/mo</span></p>
<ul >
@foreach ($plan['features'] as $feature)
<li>✓ {{ $feature }}</li>
@endforeach
</ul>
{{ ($this->subscribeAction)(['plan' => $key]) }}
</x-filament::section>
@endforeach
</div>
</x-filament-panels::page>Rendering ($this->subscribeAction)(['plan' => $key]) passes the plan as an action argument. Your page handles no card data at all. Stripe Checkout takes care of payment details, SCA/3-D Secure and tax collection if you enable it.
Step 6: The subscription middleware
// app/Http/Middleware/RedirectIfTeamNotSubscribed.php
namespace App\Http\Middleware;
use App\Filament\Pages\ChoosePlan;
use Closure;
use Filament\Facades\Filament;
use Illuminate\Http\Request;
use Symfony\Component\HttpFoundation\Response;
class RedirectIfTeamNotSubscribed
{
public function handle(Request $request, Closure $next): Response
{
$team = Filament::getTenant();
if ($team && ! $team->subscribed('default') && ! $team->onGenericTrial()) {
return redirect(ChoosePlan::getUrl(tenant: $team));
}
return $next($request);
}
}onGenericTrial() lets you give new teams a no-card trial. Set trial_ends_at on the team in RegisterTeam::handleRegistration().
Also Read: Laravel: Filament Blueprint Review
Step 7: Webhooks, the part you can’t skip
Subscription status changes happen in Stripe: renewals, failed payments, cancellations from the portal. Cashier registers /stripe/webhook automatically. You need to:
php artisan cashier:webhook # creates the webhook endpoint in Stripe with the right eventsExclude the route from request-forgery protection. On Laravel 13, the middleware is PreventRequestForgery:
// bootstrap/app.php
->withMiddleware(function (Middleware $middleware): void {
$middleware->preventRequestForgery(except: ['stripe/*']);
})For local development, forward events with the Stripe CLI:
stripe listen --forward-to your-app.test/stripe/webhookTo react to events (send “payment failed” emails, lock features), listen for Cashier’s Laravel\Cashier\Events\WebhookReceived or WebhookHandled events. Don’t poll Stripe from your panel.
Step 8: An internal subscriptions view for staff
Your team needs to see who pays what. In your admin panel (not the tenant panel), add a read-only resource over Cashier’s Laravel\Cashier\Subscription model:
public static function table(Table $table): Table
{
return $table
->modifyQueryUsing(fn ($query) => $query->with('owner'))
->columns([
TextColumn::make('owner.name')->label('Team')->searchable(),
TextColumn::make('stripe_price')
->label('Plan')
->formatStateUsing(fn (string $state) => collect(config('billing.plans'))
->firstWhere('price_id', $state)['name'] ?? $state),
TextColumn::make('stripe_status')->badge()->color(fn (string $state) => match ($state) {
'active', 'trialing' => 'success',
'past_due', 'incomplete' => 'warning',
default => 'danger',
}),
TextColumn::make('ends_at')->dateTime()->placeholder('Renewing'),
])
->filters([
SelectFilter::make('stripe_status')->options([
'active' => 'Active', 'trialing' => 'Trialing',
'past_due' => 'Past due', 'canceled' => 'Cancelled',
]),
]);
}Cashier’s Subscription model defines an owner relationship that points at your billable model.
Add a simple MRR stat from your local price map:
Stat::make('MRR', '£' . number_format(
Subscription::query()->whereIn('stripe_status', ['active', 'trialing'])->get()
->sum(fn ($s) => collect(config('billing.plans'))->firstWhere('price_id', $s->stripe_price)['amount'] ?? 0) / 100,
2,
)),This is an operational approximation. For finance-grade MRR (discounts, annual plans, currency), use Stripe’s own reports or Stripe Sigma.
Also Read: Claude Code & Cursor on Filament: AI Agent Rules That Work - Laravel
Testing billing without hitting Stripe
- Mock the subscription check in feature tests:
Team::factory()->hasSubscriptions()withstripe_status => 'active'makessubscribed()return true. - Test that unsubscribed teams are redirected:
get(CustomerResource::getUrl(tenant: $team))->assertRedirect(). - Keep a small suite of Stripe test-mode checks in a separate, manually triggered CI job.
FAQ
Does Filament have built-in Stripe billing?
Filament provides the hooks: a tenant billing route, a billing menu item and subscription-enforcing middleware. It ships a Laravel Spark provider. For Stripe with Cashier, you write a small BillingProvider.
Should the user or the team be billable?
In multi-tenant SaaS, the team (tenant) is usually billable. Configure Cashier with Cashier::useCustomerModel(Team::class) and point its migrations at the teams table.
How do I let customers change plan or cancel?
Redirect subscribed teams to the Stripe Customer Portal with $team->redirectToBillingPortal($returnUrl). Stripe handles plan changes, card updates, invoices and cancellations, and webhooks sync the result.
Also Read: What Is Jev? TypeSafe AI's 'System One' Model Explained (Pricing, API & Use Cases)
Why aren’t subscription changes showing up in my panel?
Webhooks aren’t reaching the app. Run php artisan cashier:webhook, set STRIPE_WEBHOOK_SECRET, and exclude stripe/* from CSRF / preventRequestForgery.
Sources and further reading
- Laravel Cashier (Stripe) documentation
- Filament multi-tenancy: billing
- Stripe Checkout
- Stripe Customer Portal
- laravel/cashier on Packagist
Related on The Web Tier: Multi-tenant SaaS with Filament v5 · Real-time dashboard widgets
