Skip to content

User Subscription Example

Jean-Marc Strauven edited this page Jul 31, 2025 · 2 revisions

πŸ‘€ User Subscription Example

A comprehensive subscription management system showcasing payment workflows, user lifecycle management, and automated billing with Laravel Statecraft.

πŸ“– Overview

This example demonstrates a sophisticated subscription workflow that includes:

  • Multi-tier subscription plans - Free, basic, premium, enterprise tiers
  • Payment processing - Automated billing, retry logic, payment failures
  • Trial management - Free trials with automatic conversion
  • Lifecycle automation - Onboarding, engagement, churn prevention
  • Admin overrides - Manual interventions and account management

🎯 Workflow States

State Diagram

pending β†’ active β†’ suspended β†’ cancelled
   ↓        ↓         ↓
trial β†’ active β†’ expired
   ↓        ↓         ↓
cancelled β†’ reactivated β†’ active
   ↓        
archived

State Definitions

  • pending - Subscription created but payment not processed
  • trial - User in free trial period
  • active - Subscription active and paid
  • suspended - Temporarily suspended (payment issues, policy violations)
  • expired - Subscription expired, grace period active
  • cancelled - User cancelled subscription
  • reactivated - Cancelled subscription restored
  • archived - Permanently archived subscription

βš™οΈ YAML Configuration

name: SubscriptionStateMachine
model: App\Models\Subscription
initial_state: pending
description: "User subscription lifecycle management"

config:
  enable_history: true
  enable_events: true
  history_limit: 50

states:
  - name: pending
    description: "Subscription awaiting initial payment"
    metadata:
      color: "#F59E0B"
      icon: "clock"
      billing_enabled: false
      access_level: "none"
      
  - name: trial
    description: "Free trial period active"
    metadata:
      color: "#3B82F6"
      icon: "gift"
      billing_enabled: false
      access_level: "trial"
      max_duration_days: 14
      
  - name: active
    description: "Subscription active and paid"
    metadata:
      color: "#10B981"
      icon: "check-circle"
      billing_enabled: true
      access_level: "full"
      
  - name: suspended
    description: "Subscription temporarily suspended"
    metadata:
      color: "#EF4444"
      icon: "pause-circle"
      billing_enabled: false
      access_level: "limited"
      
  - name: expired
    description: "Subscription expired, grace period active"
    metadata:
      color: "#F59E0B"
      icon: "exclamation-triangle"
      billing_enabled: false
      access_level: "read_only"
      grace_period_days: 7
      
  - name: cancelled
    description: "User cancelled subscription"
    metadata:
      color: "#6B7280"
      icon: "x-circle"
      billing_enabled: false
      access_level: "cancelled"
      
  - name: reactivated
    description: "Cancelled subscription being restored"
    metadata:
      color: "#8B5CF6"
      icon: "refresh"
      billing_enabled: false
      access_level: "limited"
      
  - name: archived
    description: "Permanently archived subscription"
    metadata:
      color: "#374151"
      icon: "archive"
      billing_enabled: false
      access_level: "none"

transitions:
  - name: start_trial
    description: "Begin free trial period"
    from: pending
    to: trial
    guard: IsVerifiedUserGuard
    actions: [StartTrialAction, SendWelcomeEmailAction]
    
  - name: activate_subscription
    description: "Activate paid subscription"
    from: [pending, trial, reactivated]
    to: active
    guard: HasValidPaymentGuard
    actions: [ProcessPaymentAction, ActivateSubscriptionAction, SendActivationEmailAction]
    
  - name: suspend_for_payment
    description: "Suspend due to payment failure"
    from: active
    to: suspended
    guard: PaymentFailedGuard
    actions: [SuspendAccessAction, NotifyPaymentFailureAction, ScheduleRetryAction]
    
  - name: suspend_for_violation
    description: "Suspend for policy violation"
    from: [active, trial]
    to: suspended
    guard: IsAdminOverrideGuard
    action: SuspendForViolationAction
    
  - name: expire_subscription
    description: "Mark subscription as expired"
    from: active
    to: expired
    guard: SubscriptionExpiredGuard
    action: StartGracePeriodAction
    
  - name: cancel_subscription
    description: "User cancels subscription"
    from: [active, trial, suspended, expired]
    to: cancelled
    action: CancelSubscriptionAction
    
  - name: reactivate_subscription
    description: "User wants to reactivate"
    from: cancelled
    to: reactivated
    guard:
      and:
        - not: ViolatedTermsGuard
        - not: IsBlacklistedGuard
    action: PrepareReactivationAction
    
  - name: resume_from_suspension
    description: "Resume suspended subscription"
    from: suspended
    to: active
    guard:
      and:
        - HasValidPaymentGuard
        - not: ViolatedTermsGuard
    actions: [ProcessPaymentAction, RestoreAccessAction]
    
  - name: archive_subscription
    description: "Permanently archive subscription"
    from: [cancelled, expired, suspended]
    to: archived
    guard: CanArchiveGuard
    action: ArchiveSubscriptionAction
    
  - name: admin_override_activate
    description: "Admin manually activates subscription"
    from: [suspended, expired, cancelled]
    to: active
    guard: IsAdminOverrideGuard
    action: AdminActivateAction

πŸ›‘οΈ Guards Implementation

Payment and Verification Guards

<?php

namespace App\Guards;

use Grazulex\LaravelStatecraft\Contracts\Guard;
use Illuminate\Database\Eloquent\Model;
use App\Services\PaymentService;

class HasValidPaymentGuard implements Guard
{
    public function __construct(
        private PaymentService $paymentService
    ) {}

    public function passes(Model $model, array $context = []): bool
    {
        // Check if user has valid payment method
        if (!$model->user->hasValidPaymentMethod()) {
            return false;
        }
        
        // For trial to paid conversion, check if payment can be processed
        if ($model->currentState() === 'trial') {
            return $this->paymentService->canProcessPayment(
                $model->user->defaultPaymentMethod(),
                $model->plan->price
            );
        }
        
        // For reactivation, might need to process past due amount
        if ($model->currentState() === 'reactivated') {
            $pastDue = $model->calculatePastDueAmount();
            return $this->paymentService->canProcessPayment(
                $model->user->defaultPaymentMethod(),
                $pastDue + $model->plan->price
            );
        }
        
        return true;
    }
}

class IsVerifiedUserGuard implements Guard
{
    public function passes(Model $model, array $context = []): bool
    {
        $user = $model->user;
        
        // Check if user is verified
        if (!$user->hasVerifiedEmail()) {
            return false;
        }
        
        // Check if user has completed onboarding
        if (!$user->onboarding_completed) {
            return false;
        }
        
        // Check if user is not suspended
        if ($user->is_suspended) {
            return false;
        }
        
        // Check if user hasn't exceeded trial limits
        $activeTrials = $user->subscriptions()
            ->where('status', 'trial')
            ->count();
            
        return $activeTrials === 0; // Only one trial per user
    }
}

class PaymentFailedGuard implements Guard
{
    public function passes(Model $model, array $context = []): bool
    {
        // Check context for payment failure information
        if (isset($context['payment_failed']) && $context['payment_failed']) {
            return true;
        }
        
        // Check recent payment attempts
        $recentFailures = $model->paymentAttempts()
            ->where('created_at', '>=', now()->subHours(24))
            ->where('status', 'failed')
            ->count();
            
        return $recentFailures >= 3; // 3 failures in 24 hours
    }
}

Policy and Compliance Guards

<?php

namespace App\Guards;

use Grazulex\LaravelStatecraft\Contracts\Guard;
use Illuminate\Database\Eloquent\Model;

class ViolatedTermsGuard implements Guard
{
    public function passes(Model $model, array $context = []): bool
    {
        $user = $model->user;
        
        // Check for active violations
        $activeViolations = $user->policyViolations()
            ->where('status', 'active')
            ->where('severity', '>=', 'high')
            ->count();
            
        return $activeViolations > 0;
    }
}

class IsAdminOverrideGuard implements Guard
{
    public function passes(Model $model, array $context = []): bool
    {
        $user = auth()->user();
        
        if (!$user) {
            return false;
        }
        
        // Check admin permissions
        if (!$user->hasRole(['admin', 'billing_manager'])) {
            return false;
        }
        
        // Require override reason for certain actions
        if (in_array($context['action'] ?? '', ['activate', 'suspend']) && 
            empty($context['override_reason'])) {
            return false;
        }
        
        return true;
    }
}

class CanArchiveGuard implements Guard
{
    public function passes(Model $model, array $context = []): bool
    {
        // Only archive after minimum retention period
        $retentionPeriod = now()->subYears(2);
        
        if ($model->created_at > $retentionPeriod) {
            return false;
        }
        
        // Check if all billing disputes are resolved
        $openDisputes = $model->billingDisputes()
            ->where('status', 'open')
            ->count();
            
        return $openDisputes === 0;
    }
}

class SubscriptionExpiredGuard implements Guard
{
    public function passes(Model $model, array $context = []): bool
    {
        // Check if subscription has reached end date
        if ($model->ends_at && $model->ends_at <= now()) {
            return true;
        }
        
        // Check for missed payments beyond grace period
        if ($model->next_billing_date < now()->subDays(3)) {
            return true;
        }
        
        return false;
    }
}

⚑ Actions Implementation

Subscription Lifecycle Actions

<?php

namespace App\Actions;

use Grazulex\LaravelStatecraft\Contracts\Action;
use Illuminate\Database\Eloquent\Model;
use App\Services\BillingService;
use App\Services\NotificationService;
use App\Services\AccessControlService;

class StartTrialAction implements Action
{
    public function __construct(
        private NotificationService $notificationService,
        private AccessControlService $accessControl
    ) {}

    public function execute(Model $model, array $context = []): void
    {
        $trialDays = $model->plan->trial_days ?? 14;
        
        $model->update([
            'trial_started_at' => now(),
            'trial_ends_at' => now()->addDays($trialDays),
            'status' => 'trial'
        ]);
        
        // Enable trial access
        $this->accessControl->grantTrialAccess($model->user, $model->plan);
        
        // Track trial start in analytics
        event(new TrialStarted($model));
        
        // Schedule trial ending reminder
        dispatch(new TrialEndingReminderJob($model))
            ->delay(now()->addDays($trialDays - 3));
    }
}

class ProcessPaymentAction implements Action
{
    public function __construct(
        private BillingService $billingService
    ) {}

    public function execute(Model $model, array $context = []): void
    {
        $amount = $context['amount'] ?? $model->plan->price;
        $paymentMethod = $context['payment_method'] ?? $model->user->defaultPaymentMethod();
        
        try {
            $payment = $this->billingService->processPayment([
                'amount' => $amount,
                'currency' => $model->plan->currency,
                'payment_method' => $paymentMethod,
                'subscription_id' => $model->id,
                'description' => "Subscription for {$model->plan->name}"
            ]);
            
            // Record successful payment
            $model->payments()->create([
                'amount' => $amount,
                'currency' => $model->plan->currency,
                'payment_method_id' => $paymentMethod->id,
                'transaction_id' => $payment->id,
                'status' => 'completed',
                'processed_at' => now()
            ]);
            
            // Update billing dates
            $this->updateBillingCycle($model);
            
        } catch (PaymentException $e) {
            // Record failed payment
            $model->payments()->create([
                'amount' => $amount,
                'currency' => $model->plan->currency,
                'payment_method_id' => $paymentMethod->id,
                'status' => 'failed',
                'failure_reason' => $e->getMessage(),
                'attempted_at' => now()
            ]);
            
            throw $e; // Re-throw to prevent state transition
        }
    }
    
    private function updateBillingCycle(Model $model): void
    {
        $nextBilling = match($model->plan->billing_cycle) {
            'monthly' => now()->addMonth(),
            'quarterly' => now()->addMonths(3),
            'yearly' => now()->addYear(),
            default => now()->addMonth(),
        };
        
        $model->update([
            'current_period_start' => now(),
            'current_period_end' => $nextBilling,
            'next_billing_date' => $nextBilling,
            'last_payment_at' => now()
        ]);
    }
}

class ActivateSubscriptionAction implements Action
{
    public function __construct(
        private AccessControlService $accessControl,
        private NotificationService $notificationService
    ) {}

    public function execute(Model $model, array $context = []): void
    {
        // Grant full access to the plan features
        $this->accessControl->grantFullAccess($model->user, $model->plan);
        
        // Update subscription metadata
        $model->update([
            'activated_at' => now(),
            'status' => 'active',
            'trial_ends_at' => null // Clear trial end date
        ]);
        
        // Update user's subscription tier
        $model->user->update([
            'subscription_tier' => $model->plan->tier,
            'subscription_features' => $model->plan->features
        ]);
        
        // Trigger onboarding for new subscribers
        if ($model->wasRecentlyCreated || $model->currentState() === 'trial') {
            dispatch(new StartOnboardingSequenceJob($model->user));
        }
        
        // Track activation in analytics
        event(new SubscriptionActivated($model));
    }
}

Payment and Billing Actions

<?php

namespace App\Actions;

use Grazulex\LaravelStatecraft\Contracts\Action;
use Illuminate\Database\Eloquent\Model;

class NotifyPaymentFailureAction implements Action
{
    public function __construct(
        private NotificationService $notificationService
    ) {}

    public function execute(Model $model, array $context = []): void
    {
        $failureReason = $context['failure_reason'] ?? 'Payment could not be processed';
        $nextRetry = $context['next_retry'] ?? now()->addDays(3);
        
        // Send email notification
        $this->notificationService->send($model->user, 'payment_failed', [
            'subscription' => $model,
            'failure_reason' => $failureReason,
            'next_retry_date' => $nextRetry,
            'amount_due' => $model->plan->price,
            'update_payment_url' => route('billing.payment-methods')
        ]);
        
        // Send in-app notification
        $model->user->notifications()->create([
            'type' => 'payment_failure',
            'title' => 'Payment Failed',
            'message' => "Your payment for {$model->plan->name} failed. Please update your payment method.",
            'data' => [
                'subscription_id' => $model->id,
                'amount' => $model->plan->price,
                'failure_reason' => $failureReason
            ],
            'priority' => 'high'
        ]);
        
        // Track payment failure metrics
        $model->increment('failed_payment_count');
    }
}

class ScheduleRetryAction implements Action
{
    public function execute(Model $model, array $context = []): void
    {
        $retryAttempt = $model->failed_payment_count;
        
        // Progressive delay: 3 days, 7 days, 14 days
        $delayDays = match($retryAttempt) {
            1 => 3,
            2 => 7,
            3 => 14,
            default => 14, // Max 14 days
        };
        
        $retryDate = now()->addDays($delayDays);
        
        // Schedule retry job
        dispatch(new RetryPaymentJob($model))
            ->delay($retryDate);
        
        // Update subscription with retry information
        $model->update([
            'next_retry_at' => $retryDate,
            'retry_attempt' => $retryAttempt
        ]);
        
        // If too many failures, prepare for cancellation
        if ($retryAttempt >= 3) {
            dispatch(new FinalPaymentNoticeJob($model))
                ->delay($retryDate->addDays(3));
        }
    }
}

class SuspendAccessAction implements Action
{
    public function __construct(
        private AccessControlService $accessControl
    ) {}

    public function execute(Model $model, array $context = []): void
    {
        // Revoke most access but keep basic read access
        $this->accessControl->suspendAccess($model->user, [
            'keep_read_access' => true,
            'suspend_api_access' => true,
            'suspend_premium_features' => true
        ]);
        
        // Update suspension metadata
        $model->update([
            'suspended_at' => now(),
            'suspension_reason' => $context['reason'] ?? 'Payment failure',
            'status' => 'suspended'
        ]);
        
        // Add suspension notice to user's dashboard
        $model->user->notices()->create([
            'type' => 'suspension',
            'title' => 'Account Suspended',
            'message' => 'Your subscription has been suspended. Please resolve the payment issue to restore access.',
            'dismissible' => false,
            'expires_at' => null
        ]);
    }
}

Cancellation and Reactivation Actions

<?php

namespace App\Actions;

use Grazulex\LaravelStatecraft\Contracts\Action;
use Illuminate\Database\Eloquent\Model;

class CancelSubscriptionAction implements Action
{
    public function __construct(
        private BillingService $billingService,
        private AccessControlService $accessControl,
        private NotificationService $notificationService
    ) {}

    public function execute(Model $model, array $context = []): void
    {
        $cancelReason = $context['reason'] ?? 'User requested';
        $immediateCancel = $context['immediate'] ?? false;
        
        if ($immediateCancel) {
            // Immediate cancellation
            $this->accessControl->revokeAllAccess($model->user);
            $endsAt = now();
        } else {
            // Cancel at end of billing period
            $endsAt = $model->current_period_end ?? now()->addDays(30);
        }
        
        $model->update([
            'cancelled_at' => now(),
            'cancellation_reason' => $cancelReason,
            'ends_at' => $endsAt,
            'status' => 'cancelled'
        ]);
        
        // Cancel future billing
        $this->billingService->cancelRecurringBilling($model);
        
        // Send cancellation confirmation
        $this->notificationService->send($model->user, 'subscription_cancelled', [
            'subscription' => $model,
            'reason' => $cancelReason,
            'access_until' => $endsAt,
            'reactivate_url' => route('billing.reactivate', $model->id)
        ]);
        
        // Schedule access revocation if not immediate
        if (!$immediateCancel) {
            dispatch(new RevokeAccessJob($model))->delay($endsAt);
        }
        
        // Start win-back campaign
        dispatch(new StartWinBackCampaignJob($model))
            ->delay(now()->addDays(7));
    }
}

class PrepareReactivationAction implements Action
{
    public function execute(Model $model, array $context = []): void
    {
        // Calculate any past due amounts
        $pastDue = $this->calculatePastDueAmount($model);
        
        $model->update([
            'reactivation_started_at' => now(),
            'past_due_amount' => $pastDue,
            'status' => 'reactivated'
        ]);
        
        // Send reactivation instructions
        $this->notificationService->send($model->user, 'reactivation_started', [
            'subscription' => $model,
            'past_due_amount' => $pastDue,
            'total_due' => $pastDue + $model->plan->price,
            'complete_reactivation_url' => route('billing.complete-reactivation', $model->id)
        ]);
        
        // Set timeout for reactivation
        dispatch(new ReactivationTimeoutJob($model))
            ->delay(now()->addDays(7));
    }
    
    private function calculatePastDueAmount($model): float
    {
        if (!$model->ends_at || $model->ends_at >= now()) {
            return 0;
        }
        
        $daysPastDue = now()->diffInDays($model->ends_at);
        $monthsPastDue = ceil($daysPastDue / 30);
        
        return $monthsPastDue * $model->plan->price;
    }
}

class CleanupSubscriptionAction implements Action
{
    public function execute(Model $model, array $context = []): void
    {
        // Remove all access
        $this->accessControl->revokeAllAccess($model->user);
        
        // Export user data if required
        if ($context['export_data'] ?? false) {
            dispatch(new ExportUserDataJob($model->user));
        }
        
        // Anonymize personal data after retention period
        if ($model->shouldAnonymizeData()) {
            $this->anonymizeSubscriptionData($model);
        }
        
        $model->update([
            'archived_at' => now(),
            'status' => 'archived'
        ]);
    }
    
    private function anonymizeSubscriptionData($model): void
    {
        $model->update([
            'user_id' => null,
            'payment_method_data' => null,
            'billing_address' => null,
            'personal_notes' => 'Data anonymized'
        ]);
    }
}

πŸ“Š Model Implementation

Subscription Model

<?php

namespace App\Models;

use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Grazulex\LaravelStatecraft\HasStateMachine;
use Grazulex\LaravelStatecraft\HasStateHistory;

class Subscription extends Model
{
    use HasFactory, HasStateMachine, HasStateHistory;
    
    protected $stateMachine = 'SubscriptionStateMachine';
    
    protected $fillable = [
        'user_id',
        'plan_id',
        'status',
        'trial_started_at',
        'trial_ends_at',
        'activated_at',
        'suspended_at',
        'cancelled_at',
        'ends_at',
        'current_period_start',
        'current_period_end',
        'next_billing_date',
        'last_payment_at',
        'cancellation_reason',
        'suspension_reason',
        'failed_payment_count',
        'past_due_amount',
        'next_retry_at',
        'retry_attempt'
    ];
    
    protected $casts = [
        'trial_started_at' => 'datetime',
        'trial_ends_at' => 'datetime',
        'activated_at' => 'datetime',
        'suspended_at' => 'datetime',
        'cancelled_at' => 'datetime',
        'ends_at' => 'datetime',
        'current_period_start' => 'datetime',
        'current_period_end' => 'datetime',
        'next_billing_date' => 'datetime',
        'last_payment_at' => 'datetime',
        'next_retry_at' => 'datetime',
        'past_due_amount' => 'decimal:2',
        'failed_payment_count' => 'integer',
        'retry_attempt' => 'integer'
    ];
    
    // Relationships
    public function user()
    {
        return $this->belongsTo(User::class);
    }
    
    public function plan()
    {
        return $this->belongsTo(SubscriptionPlan::class, 'plan_id');
    }
    
    public function payments()
    {
        return $this->hasMany(Payment::class);
    }
    
    public function paymentAttempts()
    {
        return $this->hasMany(PaymentAttempt::class);
    }
    
    public function billingDisputes()
    {
        return $this->hasMany(BillingDispute::class);
    }
    
    // Scopes
    public function scopeActive($query)
    {
        return $query->where('status', 'active');
    }
    
    public function scopeTrial($query)
    {
        return $query->where('status', 'trial');
    }
    
    public function scopeExpiringSoon($query, $days = 7)
    {
        return $query->where('ends_at', '<=', now()->addDays($days))
                    ->whereIn('status', ['active', 'trial']);
    }
    
    public function scopePastDue($query)
    {
        return $query->where('next_billing_date', '<', now())
                    ->where('status', 'active');
    }
    
    // Helper methods
    public function isActive(): bool
    {
        return $this->status === 'active' && 
               (!$this->ends_at || $this->ends_at > now());
    }
    
    public function isInTrial(): bool
    {
        return $this->status === 'trial' && 
               $this->trial_ends_at && 
               $this->trial_ends_at > now();
    }
    
    public function daysUntilExpiry(): ?int
    {
        if (!$this->ends_at) {
            return null;
        }
        
        return now()->diffInDays($this->ends_at, false);
    }
    
    public function getAccessLevelAttribute(): string
    {
        $stateMetadata = $this->getStateMetadata();
        return $stateMetadata['access_level'] ?? 'none';
    }
    
    public function shouldAnonymizeData(): bool
    {
        // Anonymize after 2 years of archival
        return $this->archived_at && 
               $this->archived_at < now()->subYears(2);
    }
    
    public function calculateProratedAmount($newPlan): float
    {
        if (!$this->current_period_end) {
            return $newPlan->price;
        }
        
        $daysRemaining = now()->diffInDays($this->current_period_end);
        $totalDays = $this->current_period_start->diffInDays($this->current_period_end);
        
        $prorationFactor = $daysRemaining / $totalDays;
        $currentPlanCredit = $this->plan->price * $prorationFactor;
        
        return max(0, $newPlan->price - $currentPlanCredit);
    }
}

SubscriptionPlan Model

<?php

namespace App\Models;

use Illuminate\Database\Eloquent\Model;

class SubscriptionPlan extends Model
{
    protected $fillable = [
        'name',
        'slug',
        'description',
        'price',
        'currency',
        'billing_cycle',
        'trial_days',
        'tier',
        'features',
        'limits',
        'is_active'
    ];
    
    protected $casts = [
        'price' => 'decimal:2',
        'trial_days' => 'integer',
        'features' => 'array',
        'limits' => 'array',
        'is_active' => 'boolean'
    ];
    
    public function subscriptions()
    {
        return $this->hasMany(Subscription::class, 'plan_id');
    }
    
    public function getFormattedPriceAttribute(): string
    {
        return number_format($this->price, 2) . ' ' . strtoupper($this->currency);
    }
    
    public function hasFeature(string $feature): bool
    {
        return in_array($feature, $this->features ?? []);
    }
    
    public function getLimit(string $limitType): ?int
    {
        return $this->limits[$limitType] ?? null;
    }
}

🎯 Event Integration

Subscription Event Listeners

<?php

namespace App\Listeners;

use Grazulex\LaravelStatecraft\Events\StateTransitioned;
use App\Models\Subscription;
use App\Services\AnalyticsService;
use App\Services\CustomerSuccessService;

class SubscriptionStateListener
{
    public function __construct(
        private AnalyticsService $analytics,
        private CustomerSuccessService $customerSuccess
    ) {}

    public function handle(StateTransitioned $event): void
    {
        if (!$event->model instanceof Subscription) {
            return;
        }

        $subscription = $event->model;
        
        // Track analytics for all transitions
        $this->analytics->trackSubscriptionStateChange(
            $subscription, 
            $event->from, 
            $event->to
        );
        
        // Handle specific transitions
        match ($event->to) {
            'trial' => $this->handleTrialStart($subscription),
            'active' => $this->handleActivation($subscription),
            'suspended' => $this->handleSuspension($subscription),
            'cancelled' => $this->handleCancellation($subscription),
            'expired' => $this->handleExpiration($subscription),
            default => null,
        };
    }
    
    private function handleTrialStart(Subscription $subscription): void
    {
        // Track trial conversion funnel
        $this->analytics->trackEvent('trial_started', [
            'user_id' => $subscription->user_id,
            'plan_id' => $subscription->plan_id,
            'trial_days' => $subscription->plan->trial_days
        ]);
        
        // Enroll in trial nurture sequence
        $this->customerSuccess->enrollInTrialSequence($subscription->user);
        
        // Schedule trial conversion tracking
        dispatch(new TrackTrialProgressJob($subscription))
            ->delay(now()->addDays(7));
    }
    
    private function handleActivation(Subscription $subscription): void
    {
        // Update user metrics
        $subscription->user->increment('lifetime_subscriptions');
        
        // Track revenue
        $this->analytics->trackRevenue([
            'amount' => $subscription->plan->price,
            'currency' => $subscription->plan->currency,
            'subscription_id' => $subscription->id,
            'event_type' => $subscription->wasRecentlyCreated ? 'new_subscription' : 'reactivation'
        ]);
        
        // Enroll in customer success program
        $this->customerSuccess->enrollActiveCustomer($subscription->user);
    }
    
    private function handleSuspension(Subscription $subscription): void
    {
        // Track churn risk
        $this->analytics->trackEvent('subscription_suspended', [
            'user_id' => $subscription->user_id,
            'reason' => $subscription->suspension_reason,
            'days_active' => $subscription->activated_at?->diffInDays(now())
        ]);
        
        // Trigger retention intervention
        $this->customerSuccess->triggerRetentionIntervention($subscription);
    }
    
    private function handleCancellation(Subscription $subscription): void
    {
        // Track churn
        $this->analytics->trackChurn([
            'user_id' => $subscription->user_id,
            'subscription_id' => $subscription->id,
            'reason' => $subscription->cancellation_reason,
            'lifetime_value' => $subscription->payments()->sum('amount'),
            'days_subscribed' => $subscription->activated_at?->diffInDays(now())
        ]);
        
        // Start win-back campaign
        $this->customerSuccess->startWinBackCampaign($subscription);
        
        // Collect feedback
        dispatch(new SendCancellationSurveyJob($subscription->user))
            ->delay(now()->addHours(2));
    }
    
    private function handleExpiration(Subscription $subscription): void
    {
        // Grace period analytics
        $this->analytics->trackEvent('subscription_expired', [
            'user_id' => $subscription->user_id,
            'had_payment_issues' => $subscription->failed_payment_count > 0
        ]);
        
        // Final retention attempt
        dispatch(new FinalRetentionAttemptJob($subscription))
            ->delay(now()->addHours(24));
    }
}

πŸ§ͺ Testing Examples

Feature Tests

<?php

namespace Tests\Feature;

use App\Models\Subscription;
use App\Models\User;
use App\Models\SubscriptionPlan;
use Tests\TestCase;
use Illuminate\Foundation\Testing\RefreshDatabase;

class SubscriptionWorkflowTest extends TestCase
{
    use RefreshDatabase;
    
    public function test_complete_trial_to_paid_workflow()
    {
        $user = User::factory()->verified()->create();
        $plan = SubscriptionPlan::factory()->withTrial()->create();
        
        $subscription = Subscription::factory()->create([
            'user_id' => $user->id,
            'plan_id' => $plan->id
        ]);
        
        // Start trial
        $this->assertTrue($subscription->canTransition('start_trial'));
        $subscription->transition('start_trial');
        
        $this->assertEquals('trial', $subscription->currentState());
        $this->assertNotNull($subscription->trial_started_at);
        
        // Convert to paid
        $this->mockPaymentSuccess();
        $subscription->transition('activate_subscription');
        
        $this->assertEquals('active', $subscription->currentState());
        $this->assertNotNull($subscription->activated_at);
    }
    
    public function test_payment_failure_and_recovery()
    {
        $subscription = Subscription::factory()->active()->create();
        
        // Simulate payment failure
        $this->mockPaymentFailure();
        $subscription->transition('suspend_for_payment', [
            'payment_failed' => true,
            'failure_reason' => 'Insufficient funds'
        ]);
        
        $this->assertEquals('suspended', $subscription->currentState());
        $this->assertEquals(1, $subscription->failed_payment_count);
        
        // Recover with successful payment
        $this->mockPaymentSuccess();
        $subscription->transition('resume_from_suspension');
        
        $this->assertEquals('active', $subscription->currentState());
    }
    
    public function test_cancellation_and_reactivation()
    {
        $subscription = Subscription::factory()->active()->create();
        
        // Cancel subscription
        $subscription->transition('cancel_subscription', [
            'reason' => 'Too expensive'
        ]);
        
        $this->assertEquals('cancelled', $subscription->currentState());
        $this->assertNotNull($subscription->cancelled_at);
        
        // Attempt reactivation
        $subscription->transition('reactivate_subscription');
        $this->assertEquals('reactivated', $subscription->currentState());
        
        // Complete reactivation
        $this->mockPaymentSuccess();
        $subscription->transition('activate_subscription');
        $this->assertEquals('active', $subscription->currentState());
    }
    
    private function mockPaymentSuccess()
    {
        $this->mock(PaymentService::class, function ($mock) {
            $mock->shouldReceive('processPayment')->andReturn(
                (object) ['id' => 'payment_123', 'status' => 'succeeded']
            );
        });
    }
    
    private function mockPaymentFailure()
    {
        $this->mock(PaymentService::class, function ($mock) {
            $mock->shouldReceive('processPayment')
                 ->andThrow(new PaymentException('Insufficient funds'));
        });
    }
}

πŸ“ˆ Usage Examples

API Controller Integration

<?php

namespace App\Http\Controllers\Api;

use App\Models\Subscription;
use Illuminate\Http\Request;
use Illuminate\Http\JsonResponse;

class SubscriptionController extends Controller
{
    public function startTrial(Request $request): JsonResponse
    {
        $request->validate([
            'plan_id' => 'required|exists:subscription_plans,id'
        ]);
        
        $user = auth()->user();
        $plan = SubscriptionPlan::findOrFail($request->plan_id);
        
        // Check if user already has active subscription
        if ($user->subscriptions()->active()->exists()) {
            return response()->json([
                'error' => 'User already has an active subscription'
            ], 422);
        }
        
        $subscription = Subscription::create([
            'user_id' => $user->id,
            'plan_id' => $plan->id,
            'status' => 'pending'
        ]);
        
        try {
            $subscription->transition('start_trial');
            
            return response()->json([
                'message' => 'Trial started successfully',
                'subscription' => $subscription->fresh(),
                'trial_ends_at' => $subscription->trial_ends_at,
                'access_level' => $subscription->access_level
            ]);
        } catch (\Exception $e) {
            return response()->json([
                'error' => 'Failed to start trial: ' . $e->getMessage()
            ], 500);
        }
    }
    
    public function cancelSubscription(Request $request, Subscription $subscription): JsonResponse
    {
        $this->authorize('update', $subscription);
        
        $request->validate([
            'reason' => 'required|string|max:255',
            'immediate' => 'boolean'
        ]);
        
        if (!$subscription->canTransition('cancel_subscription')) {
            return response()->json([
                'error' => 'Subscription cannot be cancelled in current state'
            ], 422);
        }
        
        try {
            $subscription->transition('cancel_subscription', [
                'reason' => $request->reason,
                'immediate' => $request->immediate ?? false
            ]);
            
            return response()->json([
                'message' => 'Subscription cancelled successfully',
                'subscription' => $subscription->fresh(),
                'access_until' => $subscription->ends_at,
                'can_reactivate' => $subscription->canTransition('reactivate_subscription')
            ]);
        } catch (\Exception $e) {
            return response()->json([
                'error' => 'Cancellation failed: ' . $e->getMessage()
            ], 500);
        }
    }
    
    public function reactivateSubscription(Request $request, Subscription $subscription): JsonResponse
    {
        $this->authorize('update', $subscription);
        
        if (!$subscription->canTransition('reactivate_subscription')) {
            return response()->json([
                'error' => 'Subscription cannot be reactivated'
            ], 422);
        }
        
        try {
            $subscription->transition('reactivate_subscription');
            
            $pastDue = $subscription->past_due_amount;
            $totalDue = $pastDue + $subscription->plan->price;
            
            return response()->json([
                'message' => 'Reactivation initiated',
                'subscription' => $subscription->fresh(),
                'past_due_amount' => $pastDue,
                'total_amount_due' => $totalDue,
                'next_steps' => 'Complete payment to activate subscription'
            ]);
        } catch (\Exception $e) {
            return response()->json([
                'error' => 'Reactivation failed: ' . $e->getMessage()
            ], 500);
        }
    }
}

πŸš€ Advanced Features

Automated Billing Retry Logic

<?php

namespace App\Jobs;

use App\Models\Subscription;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;

class RetryPaymentJob implements ShouldQueue
{
    use Queueable;
    
    public function __construct(
        private Subscription $subscription
    ) {}
    
    public function handle(): void
    {
        // Check if subscription still needs payment retry
        if ($this->subscription->currentState() !== 'suspended') {
            return;
        }
        
        try {
            // Attempt payment
            $this->subscription->transition('resume_from_suspension');
            
            // Reset retry counter on success
            $this->subscription->update([
                'failed_payment_count' => 0,
                'next_retry_at' => null,
                'retry_attempt' => 0
            ]);
            
        } catch (PaymentException $e) {
            // Schedule next retry or cancel
            $this->handleRetryFailure($e);
        }
    }
    
    private function handleRetryFailure(PaymentException $e): void
    {
        $retryAttempt = $this->subscription->retry_attempt + 1;
        
        if ($retryAttempt >= 3) {
            // Final attempt failed, cancel subscription
            $this->subscription->transition('cancel_subscription', [
                'reason' => 'Payment retry limit exceeded',
                'immediate' => true
            ]);
        } else {
            // Schedule another retry
            $this->subscription->transition('schedule_retry');
        }
    }
}

Subscription Analytics Service

<?php

namespace App\Services;

use App\Models\Subscription;
use Illuminate\Support\Facades\DB;

class SubscriptionAnalyticsService
{
    public function getChurnMetrics(array $period = []): array
    {
        $startDate = $period['start'] ?? now()->subDays(30);
        $endDate = $period['end'] ?? now();
        
        $totalSubscriptions = Subscription::where('activated_at', '<=', $endDate)->count();
        $churned = Subscription::whereBetween('cancelled_at', [$startDate, $endDate])->count();
        
        return [
            'churn_rate' => $totalSubscriptions > 0 ? ($churned / $totalSubscriptions) * 100 : 0,
            'churned_count' => $churned,
            'total_subscriptions' => $totalSubscriptions,
            'period' => ['start' => $startDate, 'end' => $endDate]
        ];
    }
    
    public function getTrialConversionRate(): float
    {
        $trialsStarted = Subscription::where('status', 'trial')
            ->orWhere('trial_started_at', '!=', null)
            ->count();
            
        $trialsConverted = Subscription::where('trial_started_at', '!=', null)
            ->where('status', 'active')
            ->count();
            
        return $trialsStarted > 0 ? ($trialsConverted / $trialsStarted) * 100 : 0;
    }
    
    public function getLifetimeValue(): array
    {
        return DB::table('subscriptions')
            ->join('payments', 'subscriptions.id', '=', 'payments.subscription_id')
            ->where('payments.status', 'completed')
            ->groupBy('subscriptions.id')
            ->select([
                DB::raw('AVG(payments.amount) as avg_payment'),
                DB::raw('SUM(payments.amount) as total_revenue'),
                DB::raw('COUNT(payments.id) as payment_count')
            ])
            ->first();
    }
}

πŸš€ Next Steps

This comprehensive subscription management example demonstrates:

  • Complete payment workflows with retry logic and failure handling
  • Flexible trial management with automatic conversion
  • Role-based access control with graduated permissions
  • Automated lifecycle management with customer success integration
  • Comprehensive analytics and churn prevention

Explore More Examples

Advanced Topics

Ready to explore event-driven patterns? Check out ⚑ Event Usage Example!

Clone this wiki locally