-
-
Notifications
You must be signed in to change notification settings - Fork 0
User Subscription Example
Jean-Marc Strauven edited this page Jul 31, 2025
·
2 revisions
A comprehensive subscription management system showcasing payment workflows, user lifecycle management, and automated billing with Laravel Statecraft.
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
pending β active β suspended β cancelled
β β β
trial β active β expired
β β β
cancelled β reactivated β active
β
archived
- 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
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
<?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
}
}
<?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;
}
}
<?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));
}
}
<?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
]);
}
}
<?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'
]);
}
}
<?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);
}
}
<?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;
}
}
<?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));
}
}
<?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'));
});
}
}
<?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);
}
}
}
<?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');
}
}
}
<?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();
}
}
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
- π Article Publishing Example - Editorial workflows
- π¦ Order Workflow Example - E-commerce processing
- β‘ Event Usage Example - Event-driven patterns
- π― Events System - Subscription event handling
- π State History - Billing audit trails
- π§ͺ Testing Guide - Testing subscription workflows
- βοΈ Configuration Guide - Advanced billing configuration
Ready to explore event-driven patterns? Check out β‘ Event Usage Example!
π― Laravel Statecraft - Advanced State Machine Implementation for Laravel
Navigate: π Home | π¦ Installation | π Basic Guide | π YAML Config | π‘ Examples
Resources: π‘οΈ Guards & Actions | π― Events | π State History | π§ͺ Testing | π¨ Commands
π Community Resources:
Made with β€οΈ for the Laravel community β’ Contribute β’ License