-
-
Notifications
You must be signed in to change notification settings - Fork 0
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.
The User Subscription Workflow manages the complete subscription lifecycle:
- Trial Period - User starts with a free trial
- Payment Setup - User provides payment method to activate subscription
- Active Subscription - Regular billing and service access
- Payment Issues - Handle payment failures and retry logic
- Suspension - Temporary suspension for payment issues
- Cancellation - Voluntary or involuntary subscription termination
# 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 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
<?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;
}
}
<?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;
}
}
}
<?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;
}
}
<?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);
}
}
<?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();
}
}
<?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;
}
}
}
<?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(),
]);
}
}
<?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
}
}
<?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);
}
}
<?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',
]);
}
}
This User Subscription example showcases:
- Trial Management - Free trial periods with automatic expiration
- Payment Processing - Recurring billing and payment failure handling
- Grace Periods - Customer-friendly payment recovery windows
- Access Control - Dynamic feature access based on subscription status
- Dunning Management - Automated retry logic for failed payments
- User Self-Service - Pause, resume, and cancellation capabilities
- Compliance - Proper cleanup and data handling for cancelled subscriptions
- Order Workflow Example - E-commerce order processing
- Article Publishing Example - Content approval processes
- Event Usage Example - Advanced event handling
Perfect for: SaaS platforms, membership sites, subscription services, recurring billing systems, and any application requiring subscription lifecycle management.
π― 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