Engineering
3 min read

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 Stripe Billing in Laravel: A Complete Guide

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:

  1. Store each webhook event with its Stripe event ID
  2. Before processing, check if you have already processed this event ID
  3. If already processed, return 200 without doing anything
  4. If new, process and mark as complete

This prevents duplicate charges, duplicate emails, and other costly bugs.

The Full Flow

  1. Tenant owner selects a plan on the pricing page
  2. Stripe Checkout session is created with the initial seat count
  3. Owner completes payment
  4. Stripe sends checkout.session.completed webhook
  5. Server creates subscription record, links to tenant
  6. Team members are invited — each invite syncs seat count with Stripe
  7. Members leave — seat count decreases with proration
  8. 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.