Per-Seat Stripe Billing in Laravel: A Complete Guide
How to properly implement per-seat Stripe billing in a Laravel multi-tenant application, including quantity-based subscriptions, proration, and webhook handling.

Per-seat billing is one of the hardest billing models to implement correctly. Stripe supports it through quantity-based subscriptions, but wiring everything together in a multi-tenant Laravel application requires careful architecture. Here is how to do it right.
Why Per-Seat Billing Is Hard
On the surface, per-seat billing sounds simple: charge $X per user per month. In practice, it requires:
- Automatic seat additions — when a team member is invited, the Stripe subscription quantity must increase
- Proration on removals — when a member leaves, they should get credit for the unused portion
- Dunning and retry logic — failed payments need automated retries and grace periods
- Webhook-driven state sync — your database must stay in sync with Stripe's state
- Idempotent processing — webhooks can be delivered more than once
The Architecture
A proper per-seat billing integration has three parts:
1. Subscription Management
When a tenant creates a team or upgrades to a paid plan, create a Stripe subscription with the initial seat count:
$subscription = $stripe->subscriptions->create([
'customer' => $tenant->stripe_customer_id,
'items' => [
['price' => $priceId, 'quantity' => $seatCount],
],
'proration_behavior' => 'create_prorations',
]);2. Seat Sync on Team Changes
When a team member is invited and accepts:
$stripe->subscriptions->update($subscriptionId, [
'items' => [
['id' => $itemId, 'quantity' => $newSeatCount],
],
'proration_behavior' => 'create_prorations',
]);When a member is removed, the same logic runs with a decreased count. Stripe handles the proration automatically.
3. Webhook Processing
Handle these Stripe events at minimum:
- invoice.payment_succeeded — Update subscription status, reset grace period
- invoice.payment_failed — Enter dunning state, notify tenant owner
- customer.subscription.updated — Sync subscription state changes
- customer.subscription.deleted — Handle cancellations and downgrades
Signature Verification
Never process a webhook without verifying its signature:
try {
$event = \Stripe\Webhook::constructEvent(
$payload,
$sigHeader,
config('services.stripe.webhook_secret')
);
} catch (\Stripe\Exception\SignatureVerificationException $e) {
return response('Invalid signature', 400);
}Idempotency
Webhooks can be delivered more than once. Your handler must be idempotent:
- Store each webhook event with its Stripe event ID
- Before processing, check if you have already processed this event ID
- If already processed, return 200 without doing anything
- If new, process and mark as complete
This prevents duplicate charges, duplicate emails, and other costly bugs.
The Full Flow
- Tenant owner selects a plan on the pricing page
- Stripe Checkout session is created with the initial seat count
- Owner completes payment
- Stripe sends
checkout.session.completedwebhook - Server creates subscription record, links to tenant
- Team members are invited — each invite syncs seat count with Stripe
- Members leave — seat count decreases with proration
- Monthly invoices are generated automatically by Stripe
This is the architecture pattern to follow when you need per-seat billing. The foundation handles the billing plumbing — subscription creation, webhook processing, and idempotency — so you can extend it for your own seat management logic.