Search

Filament + Stripe: Building a Subscription Billing Panel

Filament + Stripe: Building a Subscription Billing Panel

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’s preventRequestForgery middleware and run php 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 subscriptions table 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:

  1. In the customer-columns migration, change Schema::table('users', ...) to Schema::table('teams', ...).
  2. In the subscriptions migration, change the user_id foreign key to team_id.
php artisan migrate

Then 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 events

Exclude 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/webhook

To 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() with stripe_status => 'active' makes subscribed() 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

Related on The Web Tier: Multi-tenant SaaS with Filament v5 · Real-time dashboard widgets

TWT Staff

TWT Staff

Writes about Programming, tech news, discuss programming topics for web developers (and Web designers), and talks about SEO tools and techniques

Your experience on this site will be improved by allowing cookies Cookie Policy