Skip to content

User Subscription Example

Jean-Marc Strauven edited this page Aug 6, 2025 · 2 revisions

πŸ’³ User Subscription Example

This example demonstrates a complete subscription lifecycle management system using Laravel Statecraft. It covers trial periods, payment processing, billing cycles, payment failures, and subscription cancellations - perfect for SaaS platforms and membership sites.

🎯 Overview

The User Subscription Workflow manages the complete subscription lifecycle:

  1. Trial Period - User starts with a free trial
  2. Payment Setup - User provides payment method to activate subscription
  3. Active Subscription - Regular billing and service access
  4. Payment Issues - Handle payment failures and retry logic
  5. Suspension - Temporary suspension for payment issues
  6. Cancellation - Voluntary or involuntary subscription termination

πŸ“‹ YAML Configuration

Basic Subscription Workflow

# resources/state-machines/SubscriptionStateMachine.yaml
state_machine:
  name: subscription-workflow
  model: App\Models\Subscription
  field: status
  states: [trial, active, suspended, cancelled]
  initial: trial
  transitions:
    # Trial to Active (user provides payment)
    - from: trial
      to: active
      guard: Examples\UserSubscription\Guards\HasValidPayment
      action: Examples\UserSubscription\Actions\ProcessPayment
    
    # Trial to Cancelled (trial expires without payment)
    - from: trial
      to: cancelled
      action: Examples\UserSubscription\Actions\CleanupSubscription
    
    # Active to Suspended (payment failure)
    - from: active
      to: suspended
      action: Examples\UserSubscription\Actions\NotifyPaymentFailure
    
    # Suspended to Active (payment recovered)
    - from: suspended
      to: active
      guard: Examples\UserSubscription\Guards\HasValidPayment
      action: Examples\UserSubscription\Actions\ProcessPayment
    
    # Suspended to Cancelled (failed to recover payment)
    - from: suspended
      to: cancelled
      action: Examples\UserSubscription\Actions\CleanupSubscription
    
    # Active to Cancelled (user cancellation)
    - from: active
      to: cancelled
      action: Examples\UserSubscription\Actions\CleanupSubscription

Advanced Subscription Workflow

# Advanced workflow with more states and complex logic
name: SubscriptionStateMachine
model: App\Models\Subscription
initial_state: trial
field: status
description: "Advanced subscription lifecycle management"

states:
  - name: trial
    description: User is in free trial period
    metadata:
      color: blue
      trial_days: 14
      access_level: limited
      
  - name: active
    description: Subscription is active and billing
    metadata:
      color: green
      access_level: full
      billing_enabled: true
      
  - name: past_due
    description: Payment failed, in grace period
    metadata:
      color: orange
      access_level: limited
      grace_days: 3
      
  - name: suspended
    description: Subscription suspended for non-payment
    metadata:
      color: red
      access_level: none
      
  - name: cancelled
    description: Subscription was cancelled
    metadata:
      color: gray
      access_level: none
      final: true
      
  - name: paused
    description: Subscription temporarily paused by user
    metadata:
      color: yellow
      access_level: limited

transitions:
  - name: activate
    from: trial
    to: active
    guard:
      and:
        - HasValidPayment
        - IsVerifiedUser
        - not: ViolatedTerms
    action:
      - ProcessPayment
      - GrantFullAccess
      - SendWelcomeEmail
    metadata:
      requires_payment: true
      
  - name: expire_trial
    from: trial
    to: cancelled
    guard: TrialExpired
    action:
      - NotifyTrialExpired
      - CleanupSubscription
    metadata:
      automatic: true
      
  - name: payment_failed
    from: active
    to: past_due
    guard: PaymentFailed
    action:
      - NotifyPaymentFailure
      - ReduceAccessLevel
      - ScheduleRetry
    metadata:
      automatic: true
      retry_attempts: 3
      
  - name: recover_payment
    from: [past_due, suspended]
    to: active
    guard:
      and:
        - HasValidPayment
        - WithinGracePeriod
    action:
      - ProcessPayment
      - RestoreFullAccess
      - ClearPenalties
      
  - name: suspend
    from: past_due
    to: suspended
    guard: GracePeriodExpired
    action:
      - SuspendAccess
      - NotifySuspension
    metadata:
      automatic: true
      
  - name: cancel
    from: [trial, active, past_due, suspended, paused]
    to: cancelled
    action:
      - ProcessCancellation
      - RefundIfEligible
      - SendCancellationEmail
      - CleanupSubscription
    metadata:
      immediate: true
      
  - name: pause
    from: active
    to: paused
    guard:
      and:
        - IsVerifiedUser
        - HasPauseCredits
    action:
      - PauseSubscription
      - AdjustBilling
    metadata:
      max_duration: "3 months"
      
  - name: resume
    from: paused
    to: active
    guard: IsVerifiedUser
    action:
      - ResumeSubscription
      - RestoreFullAccess

πŸ—οΈ Model Setup

Subscription Model

<?php

namespace App\Models;

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

class Subscription extends Model
{
    use HasFactory, HasStateMachine;

    protected $stateMachine = 'SubscriptionStateMachine';

    protected $fillable = [
        'user_id',
        'plan_id',
        'status',
        'trial_ends_at',
        'starts_at',
        'ends_at',
        'cancelled_at',
        'payment_method_id',
        'last_payment_at',
        'next_billing_at',
        'amount',
        'currency',
        'billing_cycle',
        'grace_period_ends_at',
    ];

    protected $casts = [
        'trial_ends_at' => 'datetime',
        'starts_at' => 'datetime',
        'ends_at' => 'datetime',
        'cancelled_at' => 'datetime',
        'last_payment_at' => 'datetime',
        'next_billing_at' => 'datetime',
        'grace_period_ends_at' => 'datetime',
        'amount' => 'decimal:2',
    ];

    // Relationships
    public function user()
    {
        return $this->belongsTo(User::class);
    }

    public function plan()
    {
        return $this->belongsTo(Plan::class);
    }

    public function payments()
    {
        return $this->hasMany(Payment::class);
    }

    // Helper methods for guards
    public function hasValidPayment(): bool
    {
        return !is_null($this->payment_method_id) && 
               $this->payment_method_id !== '';
    }

    public function isTrialExpired(): bool
    {
        return $this->trial_ends_at && 
               $this->trial_ends_at->isPast();
    }

    public function isInGracePeriod(): bool
    {
        return $this->grace_period_ends_at && 
               $this->grace_period_ends_at->isFuture();
    }

    public function hasViolatedTerms(): bool
    {
        return $this->user->terms_violated_at !== null;
    }
}

πŸ›‘οΈ Guards Implementation

HasValidPayment Guard

<?php

namespace App\StateMachine\Guards;

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

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

    public function check(Model $model, string $from, string $to): bool
    {
        // Check if payment method exists
        if (!$model->payment_method_id) {
            return false;
        }

        // Validate payment method with payment processor
        try {
            return $this->paymentService->validatePaymentMethod(
                $model->payment_method_id,
                $model->user_id
            );
        } catch (\Exception $e) {
            return false;
        }
    }
}

IsVerifiedUser Guard

<?php

namespace App\StateMachine\Guards;

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

class IsVerifiedUser implements Guard
{
    public function check(Model $model, string $from, string $to): bool
    {
        $user = $model->user;

        if (!$user) {
            return false;
        }

        // Check if user is verified
        if (!$user->hasVerifiedEmail()) {
            return false;
        }

        // Check if user account is active
        if ($user->is_suspended || $user->is_banned) {
            return false;
        }

        return true;
    }
}

PaymentFailed Guard

<?php

namespace App\StateMachine\Guards;

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

class PaymentFailed implements GuardWithContext
{
    public function check(Model $model, string $from, string $to, array $context = []): bool
    {
        // Check if this transition is triggered by a payment failure
        $paymentFailure = $context['payment_failure'] ?? false;
        
        if (!$paymentFailure) {
            return false;
        }

        // Verify the failure reason
        $failureReason = $context['failure_reason'] ?? '';
        
        $validFailureReasons = [
            'insufficient_funds',
            'expired_card',
            'declined',
            'processing_error'
        ];

        return in_array($failureReason, $validFailureReasons);
    }
}

ViolatedTerms Guard

<?php

namespace App\StateMachine\Guards;

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

class ViolatedTerms implements Guard
{
    public function check(Model $model, string $from, string $to): bool
    {
        $user = $model->user;

        // Check if user has violated terms of service
        if ($user->terms_violated_at) {
            return true;
        }

        // Check for any active violations
        return $user->violations()
            ->where('status', 'active')
            ->where('severity', '>=', 'major')
            ->exists();
    }
}

βš™οΈ Actions Implementation

ProcessPayment Action

<?php

namespace App\StateMachine\Actions;

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

class ProcessPayment implements ActionWithContext
{
    public function __construct(
        private PaymentService $paymentService,
        private BillingService $billingService
    ) {}

    public function execute(Model $model, string $from, string $to, array $context = []): void
    {
        $amount = $context['amount'] ?? $model->amount;
        $paymentMethodId = $context['payment_method_id'] ?? $model->payment_method_id;

        try {
            // Process the payment
            $payment = $this->paymentService->charge([
                'amount' => $amount,
                'currency' => $model->currency,
                'payment_method_id' => $paymentMethodId,
                'customer_id' => $model->user_id,
                'description' => "Subscription payment for plan {$model->plan->name}",
                'metadata' => [
                    'subscription_id' => $model->id,
                    'billing_cycle' => $model->billing_cycle,
                ],
            ]);

            // Update subscription
            $model->update([
                'last_payment_at' => now(),
                'next_billing_at' => $this->billingService->calculateNextBilling(
                    $model->billing_cycle
                ),
                'grace_period_ends_at' => null,
            ]);

            // Create payment record
            $model->payments()->create([
                'amount' => $amount,
                'currency' => $model->currency,
                'payment_method_id' => $paymentMethodId,
                'gateway_payment_id' => $payment['id'],
                'status' => 'completed',
                'processed_at' => now(),
            ]);

        } catch (\Exception $e) {
            // Log payment failure
            \Log::error("Subscription payment failed", [
                'subscription_id' => $model->id,
                'error' => $e->getMessage(),
            ]);

            throw $e;
        }
    }
}

NotifyPaymentFailure Action

<?php

namespace App\StateMachine\Actions;

use Grazulex\LaravelStatecraft\Contracts\ActionWithContext;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Support\Facades\Mail;
use App\Mail\PaymentFailureNotification;

class NotifyPaymentFailure implements ActionWithContext
{
    public function execute(Model $model, string $from, string $to, array $context = []): void
    {
        $failureReason = $context['failure_reason'] ?? 'Payment processing failed';
        $retryDate = $context['retry_date'] ?? now()->addDays(3);

        // Update subscription with grace period
        $model->update([
            'grace_period_ends_at' => $retryDate,
            'payment_failure_count' => $model->payment_failure_count + 1,
        ]);

        // Send failure notification
        Mail::to($model->user->email)->send(
            new PaymentFailureNotification($model, $failureReason, $retryDate)
        );

        // Schedule payment retry
        \App\Jobs\RetrySubscriptionPayment::dispatch($model)
            ->delay($retryDate);

        // Log the failure
        $model->payments()->create([
            'amount' => $model->amount,
            'currency' => $model->currency,
            'payment_method_id' => $model->payment_method_id,
            'status' => 'failed',
            'failure_reason' => $failureReason,
            'processed_at' => now(),
        ]);
    }
}

CleanupSubscription Action

<?php

namespace App\StateMachine\Actions;

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

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

    public function execute(Model $model, string $from, string $to): void
    {
        // Set cancellation timestamp
        $model->update([
            'cancelled_at' => now(),
            'ends_at' => now(),
        ]);

        // Revoke user access
        $this->accessControl->revokeSubscriptionAccess($model->user);

        // Cancel any scheduled jobs
        $this->cancelScheduledJobs($model);

        // Update user's subscription status
        $model->user->update([
            'subscription_status' => 'cancelled',
            'access_level' => 'free',
        ]);

        // Clean up related data
        $this->cleanupRelatedData($model);
    }

    private function cancelScheduledJobs(Model $subscription): void
    {
        // Cancel retry jobs
        \App\Jobs\RetrySubscriptionPayment::where([
            'subscription_id' => $subscription->id
        ])->delete();

        // Cancel billing jobs
        \App\Jobs\ProcessSubscriptionBilling::where([
            'subscription_id' => $subscription->id
        ])->delete();
    }

    private function cleanupRelatedData(Model $subscription): void
    {
        // Archive subscription data
        // Clean up temporary data
        // Update analytics
    }
}

πŸ§ͺ Comprehensive Tests

Feature Tests

<?php

namespace Tests\Feature;

use App\Models\Subscription;
use App\Models\User;
use App\Models\Plan;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Mail;
use Tests\TestCase;

class SubscriptionWorkflowTest extends TestCase
{
    use RefreshDatabase;

    public function test_trial_to_active_subscription_flow()
    {
        Mail::fake();
        
        $user = User::factory()->verified()->create();
        $plan = Plan::factory()->create(['price' => 29.99]);
        
        $subscription = Subscription::factory()->create([
            'user_id' => $user->id,
            'plan_id' => $plan->id,
            'status' => 'trial',
            'payment_method_id' => 'pm_test_123',
        ]);

        // Test activation from trial
        $this->assertTrue($subscription->canTransitionTo('activate'));
        
        $subscription->transitionTo('activate');
        
        $this->assertEquals('active', $subscription->getCurrentState());
        $this->assertNotNull($subscription->last_payment_at);
        Mail::assertSent(\App\Mail\SubscriptionActivated::class);
    }

    public function test_payment_failure_and_recovery_flow()
    {
        $subscription = Subscription::factory()->active()->create();

        // Simulate payment failure
        $subscription->transitionTo('payment_failed', [
            'payment_failure' => true,
            'failure_reason' => 'insufficient_funds',
        ]);
        
        $this->assertEquals('past_due', $subscription->getCurrentState());
        $this->assertNotNull($subscription->grace_period_ends_at);

        // Test payment recovery
        $subscription->transitionTo('recover_payment');
        
        $this->assertEquals('active', $subscription->getCurrentState());
        $this->assertNull($subscription->grace_period_ends_at);
    }

    public function test_trial_expiration_without_payment()
    {
        $subscription = Subscription::factory()->create([
            'status' => 'trial',
            'trial_ends_at' => now()->subDay(),
            'payment_method_id' => null,
        ]);

        $subscription->transitionTo('expire_trial');
        
        $this->assertEquals('cancelled', $subscription->getCurrentState());
        $this->assertNotNull($subscription->cancelled_at);
    }

    public function test_suspension_for_non_payment()
    {
        $subscription = Subscription::factory()->create([
            'status' => 'past_due',
            'grace_period_ends_at' => now()->subDay(),
        ]);

        $subscription->transitionTo('suspend');
        
        $this->assertEquals('suspended', $subscription->getCurrentState());
    }

    public function test_user_initiated_cancellation()
    {
        $subscription = Subscription::factory()->active()->create();
        
        $this->assertTrue($subscription->canTransitionTo('cancel'));
        
        $subscription->transitionTo('cancel');
        
        $this->assertEquals('cancelled', $subscription->getCurrentState());
        $this->assertNotNull($subscription->cancelled_at);
    }
}

πŸ“Š Usage Examples

Controller Integration

<?php

namespace App\Http\Controllers;

use App\Models\Subscription;
use Illuminate\Http\Request;
use Grazulex\LaravelStatecraft\Exceptions\TransitionNotAllowedException;

class SubscriptionController extends Controller
{
    public function activate(Request $request, Subscription $subscription)
    {
        $request->validate([
            'payment_method_id' => 'required|string',
        ]);

        try {
            $subscription->transitionTo('activate', [
                'payment_method_id' => $request->payment_method_id,
            ]);

            return response()->json([
                'message' => 'Subscription activated successfully',
                'subscription' => $subscription->fresh(),
            ]);

        } catch (TransitionNotAllowedException $e) {
            return response()->json([
                'error' => 'Cannot activate subscription: ' . $e->getMessage(),
            ], 400);
        }
    }

    public function cancel(Subscription $subscription)
    {
        if (!$subscription->canTransitionTo('cancel')) {
            return response()->json([
                'error' => 'Subscription cannot be cancelled at this time',
            ], 400);
        }

        $subscription->transitionTo('cancel');

        return response()->json([
            'message' => 'Subscription cancelled successfully',
        ]);
    }

    public function pause(Subscription $subscription)
    {
        if (!$subscription->canTransitionTo('pause')) {
            return response()->json([
                'error' => 'Subscription cannot be paused',
            ], 400);
        }

        $subscription->transitionTo('pause');

        return response()->json([
            'message' => 'Subscription paused successfully',
        ]);
    }
}

🎯 Key Features Demonstrated

This User Subscription example showcases:

  1. Trial Management - Free trial periods with automatic expiration
  2. Payment Processing - Recurring billing and payment failure handling
  3. Grace Periods - Customer-friendly payment recovery windows
  4. Access Control - Dynamic feature access based on subscription status
  5. Dunning Management - Automated retry logic for failed payments
  6. User Self-Service - Pause, resume, and cancellation capabilities
  7. Compliance - Proper cleanup and data handling for cancelled subscriptions

πŸ”— Related Examples


Perfect for: SaaS platforms, membership sites, subscription services, recurring billing systems, and any application requiring subscription lifecycle management.

Clone this wiki locally