Implementing Digital Content Subscription Sales on a Website
Subscription model is more complex than one-time sales: need to manage subscription lifecycle, auto-renewal, content access based on activity, tier upgrades/downgrades, and properly handle expired cards and failed charges.
Key Concepts
Plan — pricing tier with price, period, and set of access rights (features).
Subscription — instance of specific user's subscription to specific plan.
Billing period — accounting period (month, year, quarter).
Grace period — allowance period after failed charge before access revocation.
Trial — trial period without payment.
Data Schema
Schema::create('subscription_plans', function (Blueprint $table) {
$table->id();
$table->string('name');
$table->string('slug')->unique();
$table->decimal('price_monthly', 10, 2)->nullable();
$table->decimal('price_yearly', 10, 2)->nullable();
$table->integer('trial_days')->default(0);
$table->jsonb('features'); // {"downloads_per_month": 50, "max_quality": "4k", ...}
$table->boolean('is_active')->default(true);
$table->timestamps();
});
Schema::create('subscriptions', function (Blueprint $table) {
$table->id();
$table->foreignId('user_id')->constrained();
$table->foreignId('plan_id')->constrained('subscription_plans');
$table->enum('billing_period', ['monthly', 'yearly']);
$table->enum('status', ['trialing', 'active', 'past_due', 'cancelled', 'expired']);
$table->string('payment_provider'); // stripe, yookassa, paddle
$table->string('provider_subscription_id')->nullable(); // ID in payment system
$table->string('provider_customer_id')->nullable();
$table->timestamp('trial_ends_at')->nullable();
$table->timestamp('current_period_start');
$table->timestamp('current_period_end');
$table->timestamp('grace_period_ends_at')->nullable();
$table->timestamp('cancelled_at')->nullable();
$table->timestamp('ends_at')->nullable(); // null = until cancellation
$table->timestamps();
$table->index(['user_id', 'status']);
$table->index('current_period_end');
});
Creating Subscription
class CreateSubscriptionAction
{
public function execute(User $user, Plan $plan, string $billingPeriod, string $paymentMethodId): Subscription
{
$provider = PaymentProviderFactory::make(config('subscriptions.provider'));
// Create or get customer in payment system
$customerId = $user->payment_customer_id
?? $this->createCustomer($provider, $user);
// Create subscription in payment system
$providerSub = $provider->createSubscription([
'customer' => $customerId,
'payment_method' => $paymentMethodId,
'price_id' => $plan->getProviderPriceId($billingPeriod),
'trial_end' => $plan->trial_days > 0
? now()->addDays($plan->trial_days)->timestamp
: 'now',
]);
return DB::transaction(function () use ($user, $plan, $billingPeriod, $customerId, $providerSub) {
$subscription = Subscription::create([
'user_id' => $user->id,
'plan_id' => $plan->id,
'billing_period' => $billingPeriod,
'status' => $providerSub->status, // trialing or active
'payment_provider' => config('subscriptions.provider'),
'provider_subscription_id' => $providerSub->id,
'provider_customer_id' => $customerId,
'trial_ends_at' => $plan->trial_days > 0
? now()->addDays($plan->trial_days)
: null,
'current_period_start' => now(),
'current_period_end' => $this->calcPeriodEnd($billingPeriod),
]);
event(new SubscriptionCreatedEvent($subscription));
return $subscription;
});
}
}
Webhook from Payment System
All subscription status changes arrive via webhook:
class StripeWebhookHandler
{
public function handle(array $payload): void
{
match($payload['type']) {
'invoice.paid' => $this->onInvoicePaid($payload),
'invoice.payment_failed' => $this->onPaymentFailed($payload),
'customer.subscription.updated' => $this->onSubscriptionUpdated($payload),
'customer.subscription.deleted' => $this->onSubscriptionDeleted($payload),
default => null,
};
}
private function onInvoicePaid(array $payload): void
{
$providerSubId = $payload['data']['object']['subscription'];
$sub = Subscription::where('provider_subscription_id', $providerSubId)->firstOrFail();
$sub->update([
'status' => 'active',
'grace_period_ends_at' => null,
'current_period_start' => Carbon::createFromTimestamp($payload['data']['object']['period_start']),
'current_period_end' => Carbon::createFromTimestamp($payload['data']['object']['period_end']),
]);
event(new SubscriptionRenewedEvent($sub));
}
private function onPaymentFailed(array $payload): void
{
$providerSubId = $payload['data']['object']['subscription'];
$sub = Subscription::where('provider_subscription_id', $providerSubId)->firstOrFail();
$sub->update([
'status' => 'past_due',
'grace_period_ends_at' => now()->addDays(config('subscriptions.grace_period_days', 3)),
]);
// Notify user
$sub->user->notify(new PaymentFailedNotification($sub));
event(new SubscriptionPaymentFailedEvent($sub));
}
}
Content Access Control
class SubscriptionAccessGuard
{
public function canAccess(User $user, string $feature): bool
{
$subscription = $user->activeSubscription();
if (!$subscription) {
return false;
}
// active or in grace period
if (!in_array($subscription->status, ['active', 'trialing', 'past_due'])) {
return false;
}
// In grace period — access preserved
if ($subscription->status === 'past_due') {
if ($subscription->grace_period_ends_at?->isPast()) {
return false;
}
}
// Check feature in pricing plan
$features = $subscription->plan->features;
return isset($features[$feature]) && $features[$feature] !== false;
}
public function getFeatureValue(User $user, string $feature): mixed
{
$subscription = $user->activeSubscription();
return $subscription?->plan->features[$feature] ?? null;
}
}
Plan Upgrade and Downgrade
class ChangePlanAction
{
public function execute(Subscription $sub, Plan $newPlan, string $billingPeriod): void
{
$provider = PaymentProviderFactory::make($sub->payment_provider);
// Stripe supports immediate upgrade with prorated calculation
$provider->updateSubscription($sub->provider_subscription_id, [
'items' => [[
'id' => $sub->provider_item_id,
'price' => $newPlan->getProviderPriceId($billingPeriod),
]],
'proration_behavior' => 'always_invoice', // invoice for difference immediately
]);
$sub->update([
'plan_id' => $newPlan->id,
'billing_period' => $billingPeriod,
]);
event(new SubscriptionPlanChangedEvent($sub, $newPlan));
}
}
Subscription Cancellation
Two cancellation options:
- Immediate — access stops immediately, refund possible
-
At period end — access until
current_period_end, no refund
public function cancel(Subscription $sub, bool $immediately = false): void
{
$provider = PaymentProviderFactory::make($sub->payment_provider);
if ($immediately) {
$provider->cancelSubscription($sub->provider_subscription_id);
$sub->update(['status' => 'cancelled', 'ends_at' => now(), 'cancelled_at' => now()]);
} else {
$provider->cancelSubscriptionAtPeriodEnd($sub->provider_subscription_id);
$sub->update(['cancelled_at' => now(), 'ends_at' => $sub->current_period_end]);
}
}
Period Limits
If tier limits downloads per month:
class MonthlyDownloadLimiter
{
public function canDownload(User $user): bool
{
$subscription = $user->activeSubscription();
$limit = $subscription?->plan->features['downloads_per_month'] ?? 0;
if ($limit === -1) return true; // unlimited
$used = DownloadEvent::where('user_id', $user->id)
->where('downloaded_at', '>=', $subscription->current_period_start)
->count();
return $used < $limit;
}
}
Implementation Timelines
| Component | Timeline |
|---|---|
| Data schema, models, basic CRUD | 2 days |
| Subscription creation + Stripe/YooKassa integration | 3 days |
| Webhook handler (payment, failure, cancellation) | 2 days |
| Content access control | 1 day |
| Plan upgrade / downgrade | 2 days |
| Personal account (subscription management) | 2 days |
| Notifications (renewal, failure, expiry) | 1 day |
| Testing all scenarios | 3 days |
Total: 16–20 working days for fully-featured subscription system.







