Skip to content

Repository files navigation

Subscriptionify

TestsLaravelPHPLicense: MIT

Feature-based subscription management for Laravel. Gateway-agnostic plans, features, usage tracking, and optional overage billing.


Table of Contents


Requirements

  • PHP 8.2+
  • Laravel 11, 12, or 13

Installation

composer require revoltify/subscriptionify

Publish the config and migrations, then run them:

php artisan vendor:publish --tag=subscriptionify-config
php artisan vendor:publish --tag=subscriptionify-migrations
php artisan migrate

This creates six tables: plans, features, feature_plan, subscriptions, feature_usages, and feature_subscribable.


Quick Start

Add the trait and contract to your subscribable model (e.g. Team, User, or Organization):

useRevoltify\Subscriptionify\Concerns\InteractsWithSubscriptions;
useRevoltify\Subscriptionify\Contracts\Subscribable;
class Team extends Model implements Subscribable
{
use InteractsWithSubscriptions;
}

Create a plan, add features, and subscribe:

useRevoltify\Subscriptionify\Models\Plan;
useRevoltify\Subscriptionify\Models\Feature;
useRevoltify\Subscriptionify\Enums\FeatureType;
useRevoltify\Subscriptionify\Enums\Interval;
// Create a plan$plan = Plan::create([
'name' => 'Pro',
'slug' => 'pro',
'billing_period' => 1,
'billing_interval' => Interval::Month,
'trial_days' => 14,
]);
// Create features and attach to plan$apiCalls = Feature::create(['name' => 'API Calls', 'slug' => 'api-calls', 'type' => FeatureType::Consumable]);
$plan->features()->attach($apiCalls, [
'value' => 10_000,
'unit_price' => '0.00100000',
]);
// Subscribe$team->subscribe($plan);
// Use features$team->consume('api-calls', 100);
$team->remainingUsage('api-calls'); // 9900$team->remainingOverage('api-calls'); // extra units affordable from balance

Plans

Plans define billing cycles, trial periods, and grace periods.

// Free plan — never expires
Plan::create(['name' => 'Free', 'slug' => 'free', 'is_free' => true]);
// Monthly plan with trial
Plan::create([
'name' => 'Pro',
'slug' => 'pro',
'billing_period' => 1,
'billing_interval' => Interval::Month,
'trial_days' => 14,
'grace_days' => 3,
]);
// Quarterly plan
Plan::create([
'name' => 'Business',
'slug' => 'business',
'billing_period' => 3,
'billing_interval' => Interval::Month,
]);
// Yearly plan
Plan::create([
'name' => 'Enterprise',
'slug' => 'enterprise',
'billing_period' => 1,
'billing_interval' => Interval::Year,
'grace_days' => 7,
]);

Plan columns

ColumnTypeDefaultDescription
namestringDisplay name
slugstringUnique identifier
descriptionstring|nullnullOptional description
is_freeboolfalseFree plans never expire (ends_at is null)
is_activebooltrueWhether the plan accepts new subscriptions
billing_periodint1Number of intervals per billing cycle
billing_intervalIntervalMonthDay, Week, Month, or Year
trial_daysint0Trial length in days (0 = no trial)
grace_daysint0Days of access after cancellation
sort_orderint0Display ordering

Plan methods

$plan->getName(); // 'Pro'$plan->getSlug(); // 'pro'$plan->getDescription(); // 'Professional plan'$plan->isFree(); // false$plan->isActive(); // true$plan->getTrialDays(); // 14$plan->hasTrialDays(); // true$plan->getBillingPeriod(); // 1$plan->getBillingInterval(); // Interval::Month$plan->getGraceDays(); // 3$plan->hasGraceDays(); // true$plan->getSortOrder(); // 0$plan->calculateEndsAt(now()); // Carbon (null for free plans)

Features

Four feature types model different SaaS quota patterns:

TypeBehaviourResetsReleasesCharges
ToggleOn/off access gate
ConsumableDepletable quotaPeriodicallyNoOn overage
LimitHard cap with releaseNoYesOn overage
MeteredPay-per-use, no capNoPer unit
useRevoltify\Subscriptionify\Models\Feature;
useRevoltify\Subscriptionify\Enums\FeatureType;
Feature::create(['name' => 'Custom Branding', 'slug' => 'branding', 'type' => FeatureType::Toggle]);
Feature::create(['name' => 'API Calls', 'slug' => 'api-calls', 'type' => FeatureType::Consumable]);
Feature::create(['name' => 'Projects', 'slug' => 'projects', 'type' => FeatureType::Limit]);
Feature::create(['name' => 'Compute Hours', 'slug' => 'compute', 'type' => FeatureType::Metered]);

Feature methods

$feature->getName(); // 'API Calls'$feature->getSlug(); // 'api-calls'$feature->getDescription(); // 'Monthly API call quota'$feature->getType(); // FeatureType::Consumable$feature->hasQuota(); // true (consumable & limit)$feature->isToggle(); // false$feature->isConsumable(); // true$feature->isLimit(); // false$feature->isMetered(); // false

Attaching features to plans

Features are attached to plans via a pivot table with allocation data:

$plan->features()->attach($feature, [
'value' => 10_000, // quota limit (0 = unlimited)'unit_price' => '0.00100000', // overage/metered price per unit'reset_period' => 1, // reset cycle length'reset_interval' => 'month', // day, week, month, or year
]);

Unlimited: Setting value to 0 grants unlimited usage for that feature.

Pivot data access

Pivot allocation data is accessed through the HasFeaturePivot contract on the pivot models (FeaturePlan, FeatureSubscribable):

$feature = $plan->features()->first();
$feature->pivot->getValue(); // 10000$feature->pivot->getUnitPrice(); // '0.00100000'$feature->pivot->getResetPeriod(); // 1$feature->pivot->getResetInterval();// Interval::Month$feature->pivot->getResetDate(); // Carbon (next reset date)

Subscriptions

Creating a subscription

$team->subscribe($plan);
// With custom end date$team->subscribe($plan, endsAt: now()->addMonths(6));

If a plan has trial_days > 0, the subscription starts in Trialing status automatically. Free plans create subscriptions with ends_at set to null (never expires).

Subscription statuses

StatusDescription
ActiveNormal active subscription
TrialingIn trial period
PastDuePayment overdue
CancelledCancelled by user
ExpiredBilling period ended

Checking status on the subscribable

$team->subscribed(); // has active/trialing subscription$team->onPlan($plan); // on a specific plan$team->onTrial(); // currently in trial$team->onFreePlan(); // on a free plan$team->canChangePlan($otherPlan); // not already on that plan

Checking status on the subscription

$subscription = $team->subscription();
$subscription->active(); // active or trialing$subscription->onTrial(); // in trial period$subscription->recurring(); // active, not trialing$subscription->canceled(); // has been cancelled$subscription->onGracePeriod(); // cancelled but still within grace period$subscription->ended(); // cancelled and past grace period$subscription->pastDue(); // marked as past due$subscription->expired(); // status is Expired, or Active with past ends_at$subscription->valid(); // active || trialing || on grace period$subscription->hasPlan('pro'); // on a specific plan by slug$subscription->daysRemaining(); // days until ends_at$subscription->trialDaysRemaining(); // days remaining in trial

Managing subscriptions

$subscription = $team->subscription();
// Change plans$subscription->changePlan($newPlan);
$subscription->changePlan($newPlan, endsAt: now()->addYear());
$subscription->changePlan($newPlan, resetUsages: true); // resets consumable usages// Renew$subscription->renew();
$subscription->renew(endsAt: now()->addYear());
// Cancel$subscription->cancel(); // at end of billing period (grace period applies)$subscription->cancelNow(); // immediately// Resume (only during grace period)$subscription->resume();
// Lifecycle$subscription->expire();
$subscription->markPastDue();

subscriptions() vs subscription()

  • $team->subscriptions() — raw MorphMany relationship (all records, any status)
  • $team->subscription() — resolves the current active/trialing subscription, cached per request

Feature Usage

All feature operations are available directly on the subscribable model:

// Check access (does the subscribable have this feature?)$team->hasFeature('api-calls');
// Check if specific units can be consumed$team->canConsume('api-calls', 100);
// Consume units (throws FeatureException if quota exceeded)$team->consume('api-calls', 100);
// Try to consume (returns false instead of throwing)$team->tryConsume('api-calls', 100);
// Check remaining plan quota$team->remainingUsage('api-calls');
// Check remaining overage capacity (balance / unit_price)// Requires HasFunds + unit_price configured, returns '0' otherwise$team->remainingOverage('api-calls');
// Check if feature has unlimited quota$team->isUnlimitedUsage('api-calls'); // true if unlimited// Release units (Limit type only — frees up slots)$team->release('projects', 1);

How consumption works per type

Typeconsume() behaviour
ToggleNo-op (access is checked via hasFeature)
ConsumableIncrements usage, resets when valid_until expires, charges overage if HasFunds
LimitIncrements usage (use release() to free slots), charges overage if HasFunds
MeteredIncrements usage and charges per unit if HasFunds

Direct Feature Grants

Grant features directly to a subscribable, independent of their plan. Grants are additive — if a plan provides 10,000 API calls and a direct grant adds 50,000, the total quota is 60,000.

// Grant with quota$team->grantFeature('api-calls', value: 50_000);
// Grant with custom unit price for overage$team->grantFeature('api-calls', value: 50_000, unitPrice: '0.00050000');
// Grant with auto-reset$team->grantFeature('reports', value: 100, resetPeriod: 1, resetInterval: Interval::Month);
// Grant unlimited (value: 0)$team->grantFeature('api-calls', value: 0);
// Revoke direct grant (plan quota still applies)$team->revokeFeature('api-calls');

Metered Billing & Overage

Implement HasFunds alongside Subscribable to enable pay-per-use and overage billing:

useRevoltify\Subscriptionify\Contracts\HasFunds;
class Team extends Model implements Subscribable, HasFunds
{
use InteractsWithSubscriptions;
/** @return numeric-string */publicfunctiongetBalance(): string
{
return$this->balance;
}
publicfunctionhasSufficientFunds(string$amount): bool
{
returnbccomp($this->balance, $amount, 8) >= 0;
}
publicfunctiondeductFunds(string$amount, string$description): void
{
/** @var numeric-string $newBalance */$newBalance = bcsub($this->balance, $amount, 8);
$this->update(['balance' => $newBalance]);
}
}

Billing behaviour per feature type

Feature TypeWithout HasFundsWith HasFunds
ToggleAccess check onlyAccess check only
ConsumableHard quota limit — exceeding throwsQuota + automatic overage charging when exceeded
LimitHard cap — exceeding throwsHard cap + automatic overage charging when exceeded
MeteredFree unlimited usage trackingCharged per unit consumed, deducted from balance

Overage kicks in when a consumable or limit feature exceeds its quota and the subscribable has both:

  1. A unit_price configured on the feature
  2. HasFunds implemented with sufficient balance

Checking remaining overage capacity

Use remainingOverage() to check how many additional overage units a subscribable can afford based on their current balance:

// Plan quota: 10,000 | Unit price: $0.001 | Balance: $50.00$team->remainingUsage('api-calls'); // '10000' — plan quota remaining$team->remainingOverage('api-calls'); // '50000' — extra units affordable (50 / 0.001)

Returns '0' when:

  • The subscribable does not implement HasFunds
  • The feature has no unit_price configured
  • The balance is zero or negative

Note: Since the balance is shared across all features, consuming overage on one feature reduces the overage capacity for all others. The value represents a point-in-time snapshot.


DTOs

FeatureInfo

Rich snapshot of a feature's current state for a subscribable:

$feature = $team->featureInfo('api-calls');
$feature->name; // 'API Calls'$feature->slug; // 'api-calls'$feature->type; // FeatureType::Consumable$feature->limit; // 10000$feature->used; // 3500$feature->remaining; // 6500$feature->percentage; // '35.00%'$feature->unlimited; // false$feature->applicable; // true (false for toggle features)$feature->validUntil; // '2026-05-01 00:00:00'$feature->overageAvailable; // true (HasFunds + unit price configured)$feature->unitPrice; // '0.00100000'$feature->resetPeriod; // 1$feature->resetInterval; // Interval::Month

SubscriptionInfo

Complete subscription snapshot for building UI:

$info = $team->subscriptionInfo();
$info->planName; // 'Pro'$info->planSlug; // 'pro'$info->isFree; // false$info->status; // SubscriptionStatus::Active$info->billingInterval; // Interval::Month$info->billingPeriod; // 1$info->startsAt; // '2026-04-01 00:00:00'$info->endsAt; // '2026-05-01 00:00:00'$info->trialEndsAt; // null$info->onTrial; // false$info->onGracePeriod; // false$info->features; // Collection<int, FeatureInfo>$info->isActive(); // true

ConsumptionResult

Returned internally after consuming units:

$result->remaining; // 6500$result->cost; // '0.00000000' or '0.50000000' (if overage)$result->usedOverage; // false or true

All features

$features = $team->allFeatures(); // Collection<int, FeatureInfo>

Middleware

Three middleware are registered automatically via the config. They throw 403 responses on failure.

MiddlewarePurposeUsage
subscribedRequires active subscriptionRoute::middleware('subscribed')
plan:{slug}Requires specific planRoute::middleware('plan:pro')
feature:{slug}Requires specific featureRoute::middleware('feature:api-calls')
Route::middleware('subscribed')->group(function () {
// Only accessible with an active subscription
});
Route::middleware('plan:pro')->group(function () {
// Only accessible on the Pro plan
});
Route::middleware('feature:api-calls')->group(function () {
// Only accessible if the subscribable has the api-calls feature
});

The subscribable is resolved via Subscriptionify::resolveSubscribable(), which defaults to auth()->user(). See Subscribable Resolver to customize.


Blade Directives

@subscribed{{-- Active subscription content --}}@endsubscribed@plan('pro')
{{-- Pro plan only content --}}@endplan@feature('custom-branding')
{{-- Custom branding enabled --}}@endfeature@onTrial{{-- Trial period notice --}}@endonTrial@onFreePlan{{-- Upgrade prompt --}}@endonFreePlan@onGracePeriod{{-- Grace period warning --}}@endonGracePeriod

Query Scopes

Query scopes are available on models that use the InteractsWithSubscriptions trait:

// All teams with active/trialing subscriptions
Team::whereSubscribed()->get();
// All teams on a specific plan
Team::whereOnPlan($proPlan)->get();
// All teams currently in trial
Team::whereOnTrial()->get();
// All teams with expired subscriptions
Team::whereExpired()->get();

Scheduled Commands

Subscriptionify ships with an artisan command to automatically expire overdue subscriptions:

php artisan subscriptionify:expire-overdue

This finds all Active subscriptions whose ends_at date has passed and transitions them to Expired status, firing a SubscriptionExpired event for each.

Scheduling

Add to your routes/console.php

useIlluminate\Support\Facades\Schedule;
Schedule::command('subscriptionify:expire-overdue')->hourly();

Tip: The expired() method on a subscription also returns true for active subscriptions with a past ends_at — providing a real-time safety net between scheduler runs.


Events

All lifecycle events are dispatched automatically:

EventDispatched when
SubscriptionCreatedA new subscription is created
SubscriptionRenewedA subscription is renewed
SubscriptionCancelledA subscription is cancelled
SubscriptionResumedA cancelled subscription is resumed
SubscriptionPlanChangedThe subscription's plan is changed
SubscriptionExpiredA subscription is expired
SubscriptionExpiringA subscription is about to expire
SubscriptionMarkedPastDueA subscription is marked as past due
FeatureConsumedFeature units are consumed
FeatureReleasedFeature units are released (limit type)
// Example: Listen for feature consumptionuseRevoltify\Subscriptionify\Events\FeatureConsumed;
class TrackApiUsage
{
publicfunctionhandle(FeatureConsumed$event): void
{
// $event->subscribable// $event->feature// $event->units// $event->remaining// $event->cost// $event->usedOverage
}
}

Exceptions

ExceptionWhen
SubscriptionExceptionAlready subscribed, cannot resume ended subscription
FeatureExceptionFeature not found, quota exceeded, non-limit release
InsufficientFundsExceptionBalance too low for metered charge or overage
useRevoltify\Subscriptionify\Exceptions\SubscriptionException;
useRevoltify\Subscriptionify\Exceptions\FeatureException;
useRevoltify\Subscriptionify\Exceptions\InsufficientFundsException;
try {
$team->consume('api-calls', 100);
} catch (FeatureException$e) {
// Quota exceeded
} catch (InsufficientFundsException$e) {
// Insufficient balance for overage
}

Configuration

Publish the config file:

php artisan vendor:publish --tag=subscriptionify-config
// config/subscriptionify.phpreturn [
// Override with your own models (must extend base or implement contracts)'models' => [
'plan' => Plan::class,
'feature' => Feature::class,
'subscription' => Subscription::class,
],
// Rename tables if they conflict (e.g. with Cashier)'tables' => [
'plans' => 'plans',
'features' => 'features',
'feature_plan' => 'feature_plan',
'subscriptions' => 'subscriptions',
'feature_usages' => 'feature_usages',
'feature_subscribable' => 'feature_subscribable',
],
// Rename middleware aliases if they conflict'middleware' => [
'subscribed' => 'subscribed',
'plan' => 'plan',
'feature' => 'feature',
],
];

Customization

Custom models

Extend the base models and register them in the config. All internal relationships resolve from config automatically.

useRevoltify\Subscriptionify\Models\PlanasBasePlan;
class Plan extends BasePlan
{
// Add your own columns, relationships, or methods
}
// config/subscriptionify.php'models' => [
'plan' => \App\Models\Plan::class,
],

Subscribable resolver

By default, auth()->user() is used as the subscribable for middleware and Blade directives. Override this in your AppServiceProvider:

useRevoltify\Subscriptionify\Subscriptionify;
publicfunctionboot(): void
{
Subscriptionify::resolveSubscribableUsing(fn () => Team::current());
}

Custom subscription resolution

Override resolveSubscription() in your model to change which subscription is resolved. The default resolves the latest Active or Trialing subscription:

useRevoltify\Subscriptionify\Enums\SubscriptionStatus;
useRevoltify\Subscriptionify\Models\Contracts\HasSubscription;
class Team extends Model implements Subscribable
{
use InteractsWithSubscriptions;
protectedfunctionresolveSubscription(): ?HasSubscription
{
return$this->subscriptions()
->whereIn('status', [
SubscriptionStatus::Active,
SubscriptionStatus::Trialing,
SubscriptionStatus::PastDue, // also include past-due
])
->with('plan')
->latest()
->first();
}
}

subscription() is final — override resolveSubscription() instead. The caching layer stays intact.


Testing

./vendor/bin/pest

About

Feature-based subscription management for Laravel

Resources

Contributing

Stars

170 stars

Watchers

2 watching

Forks

Releases

Packages

Used by

Contributors

Languages