payment & capabilities system implementation guide
Payment & Capabilities System - Implementation Guide
This document provides a complete guide to implementing a team-based payment and capabilities system using Laravel Cashier and Stripe.
Table of Contents
- System Overview
- Architecture Diagram
- Environment Configuration
- Database Schema
- Offer & Capability Configuration
- Core Enums
- Team Model Setup
- Payment Methods
- Stripe Webhook Handling
- Capability Enforcement
- License Key System
- Frontend Implementation
- Step-by-Step Implementation
System Overview
This payment system supports three acquisition channels:
| Channel | Type | Handled By |
|---|---|---|
| Recurring Subscriptions | Monthly/Yearly | StripeSubscriptionController via Stripe Checkout |
| Lifetime Payments | One-time | StripePaymentLinkController via Stripe Payment Links |
| License Keys | Pre-generated codes | LicenseKeyController + LicenseKey model |
All channels update the Team model's offer and capabilities fields, which control feature access.
Core Concepts
- Team: The billable entity (not User). Uses Laravel Cashier's
Billabletrait - Offer: The plan tier (STARTER, PLUS, PRO, ENTERPRISE)
- Frequency: Billing period (MONTHLY, YEARLY, LIFETIME)
- Capabilities: Feature limits stored as JSON on the Team model
Architecture Diagram
┌─────────────────────────────────────────────────────────────────────┐
│ PAYMENT FLOWS │
├─────────────────────────────────────────────────────────────────────┤
│ │
│ ┌──────────────────┐ ┌──────────────────┐ ┌────────────────┐ │
│ │ Subscription │ │ Payment Link │ │ License Key │ │
│ │ (Recurring) │ │ (Lifetime) │ │ (Special) │ │
│ └────────┬─────────┘ └────────┬─────────┘ └───────┬────────┘ │
│ │ │ │ │
│ ▼ ▼ ▼ │
│ ┌──────────────────────────────────────────────────────────────┐ │
│ │ Stripe Webhooks │ │
│ │ customer.subscription.* | invoice.paid │ │
│ └──────────────────────────────┬───────────────────────────────┘ │
│ │ │
│ ▼ │
│ ┌──────────────────────────────────────────────────────────────┐ │
│ │ StripeEventListener │ │
│ │ - updateTeamOfferFromSubscription() │ │
│ │ - updateTeamOfferFromPaymentLink() │ │
│ │ - handleAffiliation() │ │
│ └──────────────────────────────┬───────────────────────────────┘ │
│ │ │
│ ▼ │
│ ┌──────────────────────────────────────────────────────────────┐ │
│ │ Team Model Updated │ │
│ │ offer: "PLUS" | capabilities: {...} │ │
│ └──────────────────────────────────────────────────────────────┘ │
│ │
└─────────────────────────────────────────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────────────────┐
│ CAPABILITY ENFORCEMENT │
├─────────────────────────────────────────────────────────────────────┤
│ │
│ ┌───────────────────┐ ┌───────────────────┐ ┌────────────────┐ │
│ │ DomainPolicy │ │ TeamInvitation │ │ LimitLead │ │
│ │ (NB_INTERNAL_ │ │ Policy │ │ Pagination │ │
│ │ DOMAINS) │ │ (NB_TEAM_USERS) │ │ (NB_LEADS) │ │
│ └───────────────────┘ └───────────────────┘ └────────────────┘ │
│ │
└─────────────────────────────────────────────────────────────────────┘
Environment Configuration
Add these environment variables to your .env:
# Stripe API Keys
STRIPE_KEY=pk_test_xxxx # Publishable key
STRIPE_SECRET=sk_test_xxxx # Secret key
STRIPE_WEBHOOK_SECRET=whsec_xxxx # Webhook signing secret
# Stripe Price IDs - Monthly Subscriptions
STRIPE_PRICE_OFFER_1_MONTHLY=price_xxxx
STRIPE_PRICE_OFFER_2_MONTHLY=price_xxxx
STRIPE_PRICE_OFFER_3_MONTHLY=price_xxxx
STRIPE_PRICE_OFFER_4_MONTHLY=price_xxxx
# Stripe Price IDs - Yearly Subscriptions
STRIPE_PRICE_OFFER_1_YEARLY=price_xxxx
STRIPE_PRICE_OFFER_2_YEARLY=price_xxxx
STRIPE_PRICE_OFFER_3_YEARLY=price_xxxx
STRIPE_PRICE_OFFER_4_YEARLY=price_xxxx
# Stripe Price IDs - Lifetime (One-time)
STRIPE_PRICE_OFFER_1_LIFETIME=price_xxxx
STRIPE_PRICE_OFFER_2_LIFETIME=price_xxxx
STRIPE_PRICE_OFFER_3_LIFETIME=price_xxxx
STRIPE_PRICE_OFFER_4_LIFETIME=price_xxxx
# Cashier Configuration
CASHIER_CURRENCY=usd
CASHIER_CURRENCY_LOCALE=en
Database Schema
1. Teams Table
// database/migrations/xxxx_create_teams_table.php
Schema::create('teams', function (Blueprint $table) {
$table->id();
$table->uuid()->index();
$table->foreignId('user_id')->index();
$table->string('name');
$table->boolean('personal_team');
$table->string('offer')->default('DEFAULT'); // Offer enum value
$table->json('capabilities'); // Feature limits JSON
$table->timestamps();
$table->softDeletes();
});
Note: Laravel Cashier adds additional columns automatically:
stripe_idpm_typepm_last_fourtrial_ends_at
2. Licenses Table (Lifetime Purchases)
// database/migrations/xxxx_create_licenses_table.php
Schema::create('licenses', function (Blueprint $table) {
$table->id();
$table->string('price_id')->nullable(); // Stripe price ID
$table->foreignId('license_key_id')->nullable(); // For special license keys
$table->foreignId('team_id');
$table->timestamps();
});
3. License Keys Table (Pre-generated Codes)
// database/migrations/xxxx_create_license_keys_table.php
Schema::create('license_keys', function (Blueprint $table) {
$table->id();
$table->uuid('key')->unique(); // The actual license key
$table->string('offer_id'); // Reference to offers config path
$table->timestamps();
});
4. Leads Table (for capability tracking)
// Important: leads have a team_number for capability enforcement
Schema::create('leads', function (Blueprint $table) {
// ... other columns
$table->foreignId('team_id');
$table->unsignedInteger('team_number'); // Sequential number per team
$table->timestamps();
});
Offer & Capability Configuration
Config File: config/offers.php
<?php
use App\Enums\Offer;
use App\Enums\OfferFrequency;
use App\Enums\TeamCapabilities;
return [
// MONTHLY PLANS
OfferFrequency::MONTHLY => [
Offer::OFFER_1 => [
'name' => 'Starter',
'description' => "Perfect for individuals getting started",
'price' => 9_00, // Price in cents
'priceId' => env('STRIPE_PRICE_OFFER_1_MONTHLY'),
'capabilities' => [
TeamCapabilities::NB_LEADS => 500,
TeamCapabilities::NB_INTERNAL_DOMAINS => 3,
TeamCapabilities::NB_TEAM_USERS => 1,
],
],
Offer::OFFER_2 => [
'name' => 'Plus',
'description' => "For growing businesses",
'price' => 19_00,
'priceId' => env('STRIPE_PRICE_OFFER_2_MONTHLY'),
'capabilities' => [
TeamCapabilities::NB_LEADS => 5000,
TeamCapabilities::NB_INTERNAL_DOMAINS => 20,
TeamCapabilities::NB_TEAM_USERS => 3,
],
],
Offer::OFFER_3 => [
'name' => 'Pro',
'description' => "For agencies and professionals",
'price' => 79_00,
'priceId' => env('STRIPE_PRICE_OFFER_3_MONTHLY'),
'capabilities' => [
TeamCapabilities::NB_LEADS => 15000,
TeamCapabilities::NB_INTERNAL_DOMAINS => -1, // -1 = unlimited
TeamCapabilities::NB_TEAM_USERS => 5,
],
],
Offer::OFFER_4 => [
'name' => 'Enterprise',
'description' => "Unlimited power for large teams",
'price' => 129_00,
'priceId' => env('STRIPE_PRICE_OFFER_4_MONTHLY'),
'capabilities' => [
TeamCapabilities::NB_LEADS => -1, // -1 = unlimited
TeamCapabilities::NB_INTERNAL_DOMAINS => -1,
TeamCapabilities::NB_TEAM_USERS => -1,
],
'is_unlimited' => true,
],
],
// YEARLY PLANS
OfferFrequency::YEARLY => [
// Same structure as monthly with different prices
],
// LIFETIME PLANS (One-time purchase)
OfferFrequency::LIFETIME => [
// Same structure with one-time pricing
],
// SPECIAL OFFERS (License keys, partnerships)
'special' => [
'superuser' => [
'name' => 'SuperUser',
'description' => "Unlimited access for super users",
'price' => 0,
'priceId' => null,
'capabilities' => [
TeamCapabilities::NB_LEADS => -1,
TeamCapabilities::NB_INTERNAL_DOMAINS => -1,
TeamCapabilities::NB_TEAM_USERS => -1,
],
'is_unlimited' => true,
],
'partner-1' => [
// Partner-specific offers structure
Offer::OFFER_1 => [
'name' => 'Starter (Partner Offer)',
// ... same structure
],
],
],
];
Core Enums
1. Offer Enum
// app/Enums/Offer.php
<?php
namespace App\Enums;
final class Offer
{
const DEFAULT = 'DEFAULT'; // Free tier
const OFFER_1 = 'STARTER';
const OFFER_2 = 'PLUS';
const OFFER_3 = 'PRO';
const OFFER_4 = 'ENTERPRISE';
/**
* Reverse lookup: get offer type from Stripe price ID
*/
static public function getOfferFromPriceId(string $priceId): string
{
return match ($priceId) {
config('offers')[OfferFrequency::MONTHLY][Offer::OFFER_1]['priceId'],
config('offers')[OfferFrequency::YEARLY][Offer::OFFER_1]['priceId'],
config('offers')[OfferFrequency::LIFETIME][Offer::OFFER_1]['priceId'] => self::OFFER_1,
config('offers')[OfferFrequency::MONTHLY][Offer::OFFER_2]['priceId'],
config('offers')[OfferFrequency::YEARLY][Offer::OFFER_2]['priceId'],
config('offers')[OfferFrequency::LIFETIME][Offer::OFFER_2]['priceId'] => self::OFFER_2,
config('offers')[OfferFrequency::MONTHLY][Offer::OFFER_3]['priceId'],
config('offers')[OfferFrequency::YEARLY][Offer::OFFER_3]['priceId'],
config('offers')[OfferFrequency::LIFETIME][Offer::OFFER_3]['priceId'] => self::OFFER_3,
config('offers')[OfferFrequency::MONTHLY][Offer::OFFER_4]['priceId'],
config('offers')[OfferFrequency::YEARLY][Offer::OFFER_4]['priceId'],
config('offers')[OfferFrequency::LIFETIME][Offer::OFFER_4]['priceId'] => self::OFFER_4,
default => self::DEFAULT,
};
}
}
2. OfferFrequency Enum
// app/Enums/OfferFrequency.php
<?php
namespace App\Enums;
final class OfferFrequency
{
const MONTHLY = 'MONTHLY';
const YEARLY = 'YEARLY';
const LIFETIME = 'LIFETIME';
static public function getFrequencyFromPriceId(string $priceId): string
{
return match ($priceId) {
config('offers')[OfferFrequency::MONTHLY][Offer::OFFER_1]['priceId'],
config('offers')[OfferFrequency::MONTHLY][Offer::OFFER_2]['priceId'],
config('offers')[OfferFrequency::MONTHLY][Offer::OFFER_3]['priceId'],
config('offers')[OfferFrequency::MONTHLY][Offer::OFFER_4]['priceId'] => self::MONTHLY,
config('offers')[OfferFrequency::YEARLY][Offer::OFFER_1]['priceId'],
config('offers')[OfferFrequency::YEARLY][Offer::OFFER_2]['priceId'],
config('offers')[OfferFrequency::YEARLY][Offer::OFFER_3]['priceId'],
config('offers')[OfferFrequency::YEARLY][Offer::OFFER_4]['priceId'] => self::YEARLY,
config('offers')[OfferFrequency::LIFETIME][Offer::OFFER_1]['priceId'],
config('offers')[OfferFrequency::LIFETIME][Offer::OFFER_2]['priceId'],
config('offers')[OfferFrequency::LIFETIME][Offer::OFFER_3]['priceId'],
config('offers')[OfferFrequency::LIFETIME][Offer::OFFER_4]['priceId'] => self::LIFETIME,
default => throw new \Exception('Unknown frequency'),
};
}
}
3. TeamCapabilities Enum
// app/Enums/TeamCapabilities.php
<?php
namespace App\Enums;
final class TeamCapabilities
{
const NB_LEADS = 'nb_leads';
const NB_INTERNAL_DOMAINS = 'nb_internal_domains';
const NB_TEAM_USERS = 'nb_team_users';
/**
* Default capabilities for free tier
*/
static public function getDefaults(): array
{
return [
self::NB_LEADS => 3,
self::NB_INTERNAL_DOMAINS => 3,
self::NB_TEAM_USERS => 1,
];
}
/**
* Get capabilities for a specific offer + frequency
*/
static public function getCapabilitiesFromOfferAndFrequency(string $offer, string $frequency): array
{
$config = config('offers')[$frequency][$offer]['capabilities'] ?? null;
return $config ?? self::getDefaults();
}
}
Team Model Setup
1. Configure Cashier in AppServiceProvider
// app/Providers/AppServiceProvider.php
<?php
namespace App\Providers;
use App\Models\Team;
use Illuminate\Support\ServiceProvider;
use Laravel\Cashier\Cashier;
class AppServiceProvider extends ServiceProvider
{
public function boot(): void
{
// Use Team as the billable model (not User)
Cashier::useCustomerModel(Team::class);
// Enable automatic tax calculation (optional)
Cashier::calculateTaxes();
}
}
2. Team Model
// app/Models/Team.php
<?php
namespace App\Models;
use Laravel\Cashier\Billable;
use Laravel\Jetstream\Team as JetstreamTeam;
class Team extends JetstreamTeam
{
use Billable; // Critical: adds Stripe subscription methods
protected $casts = [
'personal_team' => 'boolean',
'capabilities' => 'array', // JSON to array
];
protected $fillable = [
'name',
'personal_team',
'offer',
'capabilities',
];
// Relationship to license (for lifetime purchases)
public function license()
{
return $this->hasOne(License::class);
}
// Relationship to leads (for capability tracking)
public function leads()
{
return $this->hasMany(Lead::class);
}
}
3. Team Creation (with default capabilities)
// app/Actions/Jetstream/CreateTeam.php
public function create(User $user, array $input): Team
{
$user->switchTeam($team = $user->ownedTeams()->create([
'name' => $input['name'],
'personal_team' => false,
'offer' => Offer::DEFAULT,
'capabilities' => TeamCapabilities::getDefaults(),
]));
return $team;
}
Payment Methods
1. Recurring Subscriptions (Monthly/Yearly)
// app/Http/Controllers/StripeSubscriptionController.php
<?php
namespace App\Http\Controllers;
use Illuminate\Http\Request;
use Illuminate\Validation\Rule;
class StripeSubscriptionController extends Controller
{
public function __invoke(Request $request)
{
// If already subscribed, redirect to billing portal
if (team()->subscribed()) {
return team()->redirectToBillingPortal(route('dashboard'));
}
// Validate the price ID
$request->validate([
'price_id' => [
'nullable',
Rule::in([
...collect(config('offers.MONTHLY'))
->map(fn ($offer) => $offer['priceId'])
->values(),
...collect(config('offers.YEARLY'))
->map(fn ($offer) => $offer['priceId'])
->values(),
]),
],
]);
// Create subscription via Stripe Checkout
if ($request->price_id) {
return team()->newSubscription('default', $request->price_id)
->allowPromotionCodes()
->checkout([
'success_url' => route('dashboard'),
'cancel_url' => route('dashboard'),
]);
}
return redirect()->route('offers');
}
}
2. Lifetime Purchases (One-time Payment)
// app/Http/Controllers/StripePaymentLinkController.php
<?php
namespace App\Http\Controllers;
use Illuminate\Http\Request;
use Illuminate\Validation\Rule;
use Laravel\Cashier\Cashier;
class StripePaymentLinkController extends Controller
{
public function __invoke(Request $request)
{
$request->validate([
'price_id' => [
'required',
Rule::in([
...collect(config('offers.LIFETIME'))
->map(fn ($offer) => $offer['priceId'])
->values(),
]),
],
]);
// Create Stripe Payment Link
$response = Cashier::stripe()->paymentLinks->create([
'line_items' => [
[
'price' => $request->price_id,
'quantity' => 1,
],
],
'automatic_tax' => ['enabled' => true],
'invoice_creation' => [
'enabled' => true,
'invoice_data' => [
// Critical: team_uuid for webhook identification
'metadata' => ['team_uuid' => $request->user()->currentTeam->uuid],
],
],
'allow_promotion_codes' => true,
'billing_address_collection' => 'required',
'payment_method_types' => ['card'],
'after_completion' => [
'type' => 'redirect',
'redirect' => ['url' => route('dashboard')],
],
'tax_id_collection' => ['enabled' => true],
]);
return redirect($response['url'] . '?prefilled_email=' . $request->user()->email);
}
}
3. Routes Configuration
// routes/web.php
Route::middleware(['auth:sanctum', 'verified'])->group(function () {
Route::get('stripe/subscription', StripeSubscriptionController::class)
->name('stripe.subscription');
Route::get('stripe/payment-link', StripePaymentLinkController::class)
->name('stripe.payment-link');
Route::get('offers', OfferController::class)->name('offers');
Route::get('my-subscription', UserSubscriptionController::class)
->name('user-subscriptions');
});
Stripe Webhook Handling
1. Register the Listener
// app/Providers/EventServiceProvider.php
use Laravel\Cashier\Events\WebhookReceived;
use App\Listeners\StripeEventListener;
protected $listen = [
WebhookReceived::class => [
StripeEventListener::class,
],
];
2. Stripe Event Listener
// app/Listeners/StripeEventListener.php
<?php
namespace App\Listeners;
use App\Enums\Offer;
use App\Enums\OfferFrequency;
use App\Enums\TeamCapabilities;
use App\Models\Team;
use Illuminate\Support\Str;
use Laravel\Cashier\Cashier;
use Laravel\Cashier\Events\WebhookReceived;
class StripeEventListener
{
public function handle(WebhookReceived $event): void
{
$this->updateTeamOfferFromSubscription($event);
$this->updateTeamOfferFromPaymentLink($event);
}
/**
* Handle subscription events (create, update, delete)
*/
private function updateTeamOfferFromSubscription(WebhookReceived $event): void
{
if (Str::startsWith($event->payload['type'], 'customer.subscription')) {
$data = $event->payload['data']['object'];
// Find team by Stripe customer ID
$team = Cashier::findBillable($data['customer']);
if (!$team) {
return;
}
if ($event->payload['type'] === 'customer.subscription.deleted') {
// Subscription cancelled - revert to defaults
$team->offer = Offer::DEFAULT;
$team->capabilities = TeamCapabilities::getDefaults();
} else {
// Subscription created/updated - apply new capabilities
$offer = Offer::getOfferFromPriceId($data['plan']['id']);
$frequency = OfferFrequency::getFrequencyFromPriceId($data['plan']['id']);
$team->offer = $offer;
$team->capabilities = TeamCapabilities::getCapabilitiesFromOfferAndFrequency(
$offer,
$frequency
);
// Update default payment method if provided
if (!empty($data['default_payment_method'])) {
$team->updateDefaultPaymentMethod($data['default_payment_method']);
}
}
$team->save();
}
}
/**
* Handle one-time payment (lifetime) via invoice.paid
*/
private function updateTeamOfferFromPaymentLink(WebhookReceived $event): void
{
if ($event->payload['type'] === 'invoice.paid') {
$data = $event->payload['data']['object'];
$subscription = $data['subscription'];
// Only process if NOT a subscription payment
if (empty($subscription)) {
$price = $data['lines']['data'][0]['price']['id'];
$offer = Offer::getOfferFromPriceId($price);
// Find team by UUID from invoice metadata
$team = Team::query()
->where('uuid', $data['metadata']['team_uuid'])
->firstOrFail();
$team->offer = $offer;
$team->capabilities = TeamCapabilities::getCapabilitiesFromOfferAndFrequency(
$offer,
OfferFrequency::LIFETIME
);
$team->save();
// Create license record for lifetime purchase
$team->license()->create([
'price_id' => $price,
]);
}
}
}
}
Capability Enforcement
1. Domain Creation Policy
// app/Policies/DomainPolicy.php
<?php
namespace App\Policies;
use App\Enums\TeamCapabilities;
use App\Models\User;
use Illuminate\Auth\Access\Response;
class DomainPolicy
{
public function create(User $user): Response
{
$team = $user->currentTeam;
// -1 means unlimited
if (
$team->capabilities[TeamCapabilities::NB_INTERNAL_DOMAINS] !== -1
&& $team->domains()->count() >= $team->capabilities[TeamCapabilities::NB_INTERNAL_DOMAINS]
) {
return Response::deny(__('Maximum number of domains reached'));
}
return Response::allow();
}
}
2. Team Invitation Policy
// app/Policies/TeamInvitationPolicy.php
public function create(User $user): Response
{
$team = $user->currentTeam;
$usersAndInvitationsCount = $team->allUsers()->count()
+ $team->teamInvitations()->count();
if (
$team->capabilities[TeamCapabilities::NB_TEAM_USERS] !== -1
&& $usersAndInvitationsCount >= $team->capabilities[TeamCapabilities::NB_TEAM_USERS]
) {
return Response::deny(__('Maximum team member reached'));
}
return Response::allow();
}
3. Lead Data Masking (Soft Limit)
Leads are captured but masked when over limit (data still saved):
// app/Actions/LimitLeadPagination.php
<?php
namespace App\Actions;
use App\Enums\TeamCapabilities;
use App\Models\Team;
use Illuminate\Pagination\LengthAwarePaginator;
class LimitLeadPagination
{
public function execute(LengthAwarePaginator $leads, Team $team)
{
$leads->getCollection()->transform(function ($lead) use ($team) {
// If over capability limit, mask the data
if (
$team->capabilities[TeamCapabilities::NB_LEADS] !== -1
&& $lead->team_number > $team->capabilities[TeamCapabilities::NB_LEADS]
) {
return [
'email' => 'xxxxx@xxx.com',
'firstname' => 'xxxxx',
'lastname' => 'xxxxx',
'phone' => 'xxxxx',
'has_accepted_marketing' => false,
'data' => [],
'team_id' => $team->id,
'created_at' => now(),
'blocked' => true, // Flag for UI
];
}
return $lead;
});
return $leads;
}
}
4. Automation Enforcement
Automations only trigger for leads within limit:
// app/Listeners/ProcessAutomations.php
public function shouldQueue($event)
{
return $event->lead->team->capabilities[TeamCapabilities::NB_LEADS] === -1
|| $event->lead->team_number <= $event->lead->team->capabilities[TeamCapabilities::NB_LEADS];
}
5. Lead Export Enforcement
// app/Jobs/ExportLeadsInCSV.php
$maxCapabilities = $this->team->capabilities[TeamCapabilities::NB_LEADS];
if ($maxCapabilities !== -1) {
$query = $query->where('team_number', '<=', $maxCapabilities);
}
License Key System
For partners or special offers, pre-generated license keys can be used.
1. LicenseKey Model
// app/Models/LicenseKey.php
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Casts\Attribute;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Support\Str;
class LicenseKey extends Model
{
protected $fillable = ['key', 'offer_id'];
// Check if this is a special (partner) offer
protected function isSpecial(): Attribute
{
return Attribute::make(
get: fn () => Str::startsWith($this->offer_id, 'special')
);
}
// Get the offer configuration
protected function offer(): Attribute
{
return Attribute::make(
get: fn () => data_get(config('offers'), $this->offer_id)
);
}
// Check if already redeemed
protected function alreadyUsed(): Attribute
{
return Attribute::make(
get: fn () => $this->license()->exists()
);
}
public function license()
{
return $this->hasOne(License::class);
}
}
2. License Redemption Controller
// app/Http/Controllers/LicenseKeyController.php
public function store(StoreLicenseKeyRequest $request)
{
return DB::transaction(function () use ($request) {
$validatedData = $request->validated();
$licenseKey = LicenseKey::where('key', $validatedData['license_key'])->first();
// Create user (includes team creation)
$user = (new CreateNewUser())->create($request->all());
// Create license record
$license = $licenseKey->license()->create([
'team_id' => $user->currentTeam->id,
]);
$user->currentTeam->license()->save($license);
// Apply capabilities from license key
$offer = data_get(config('offers'), $licenseKey->offer_id);
$user->currentTeam->capabilities = $offer['capabilities'];
$user->currentTeam->save();
event(new Registered($user));
Auth::login($user);
return redirect()->back();
});
}
3. Artisan Command for Manual License Assignment
// app/Console/Commands/AssociateUserLicenseCommand.php
// Usage: php artisan license-key:associate {teamId} {licenseId}
public function handle()
{
$team = Team::find($this->argument('teamId'));
$licenseKey = LicenseKey::find($this->argument('licenseId'));
DB::transaction(function () use ($team, $licenseKey) {
$license = $licenseKey->license()->create([
'team_id' => $team->id,
]);
$team->license()->save($license);
$offer = data_get(config('offers'), $licenseKey->offer_id);
$team->capabilities = $offer['capabilities'];
$team->save();
});
}
Frontend Implementation
1. Offers Page
// resources/js/views/offers.tsx
function OfferItem(props: { type: "recurring" | "one-time", priceId: string, ... }) {
return (
<Button asChild>
<a
href={route(
props.type === "recurring"
? "stripe.subscription"
: "stripe.payment-link",
{ price_id: props.priceId }
)}
>
Choose this plan
</a>
</Button>
);
}
2. Capability Display Component
// resources/js/components/capability-list.tsx
export function CapabilityList(props: {
nb_leads: number;
nb_internal_domains: number;
nb_team_users: number;
}) {
const capabilities = [
{
name: props.nb_leads > -1
? `Up to ${props.nb_leads} leads`
: "Unlimited leads",
},
{
name: props.nb_internal_domains > -1
? `Up to ${props.nb_internal_domains} domains`
: "Unlimited domains",
},
{
name: props.nb_team_users > -1
? `Up to ${props.nb_team_users} team members`
: "Unlimited team members",
},
];
return capabilities.map((cap, index) => (
<li key={index}>{cap.name}</li>
));
}
3. User Subscription Page
Pass capabilities to the frontend:
// app/Http/Controllers/UserSubscriptionController.php
return Inertia::render('user-subscription', [
'lead_count' => team()->leads()->count(),
'offer' => team()->offer,
'capabilities' => team()->capabilities,
'paid_price' => $paidPrice,
]);
Step-by-Step Implementation
Phase 1: Database Setup
Create migrations:
php artisan make:migration create_teams_table php artisan make:migration create_licenses_table php artisan make:migration create_license_keys_tableRun Cashier installation:
php artisan vendor:publish --tag=cashier-migrations php artisan migrate
Phase 2: Configuration
- Create
config/offers.phpwith all plans and capabilities - Create enums:
Offer,OfferFrequency,TeamCapabilities - Add Stripe environment variables
Phase 3: Model Setup
- Add
Billabletrait to Team model - Configure
Cashier::useCustomerModel(Team::class)in AppServiceProvider - Add
capabilitiescast andofferfield to Team model
Phase 4: Payment Controllers
- Create
StripeSubscriptionControllerfor recurring - Create
StripePaymentLinkControllerfor lifetime - Add routes
Phase 5: Webhook Handling
- Register
StripeEventListenerin EventServiceProvider - Implement
updateTeamOfferFromSubscription() - Implement
updateTeamOfferFromPaymentLink()
Phase 6: Capability Enforcement
- Create policies for domains, team invitations
- Implement
LimitLeadPaginationaction - Add capability checks to relevant listeners/jobs
Phase 7: Frontend
- Create offers page with plan selection
- Create user subscription management page
- Implement capability display components
Phase 8: Testing
- Test with Stripe CLI:
stripe listen --forward-to localhost:8000/stripe/webhook - Test subscription creation, update, cancellation
- Test lifetime purchase flow
- Verify capability enforcement
Key Webhook Events to Handle
| Event | Purpose |
|---|---|
customer.subscription.created |
Apply capabilities on new subscription |
customer.subscription.updated |
Update capabilities on plan change |
customer.subscription.deleted |
Revert to defaults on cancellation |
invoice.paid |
Handle lifetime purchases (non-subscription) |
Important Notes
- Team is Billable, Not User: Subscriptions are tied to teams, allowing multiple users to share a subscription
- Capabilities = -1 means Unlimited: Always check for -1 before comparing limits
- Leads are Never Deleted: Even over-limit leads are captured but masked in the UI
- License Keys are Single-Use: Once redeemed, they cannot be used again
- Webhooks are Critical: All capability updates happen via webhooks, not on redirect