A flexible plans and subscription management system for Laravel. Manage SaaS plans, features, and subscriber usage tracking without coupling to any payment provider.
Inspired by rinvex/laravel-subscriptions, which is now abandoned. This package modernizes the codebase for Laravel 11/12/13, PHP 8.3+, and current Laravel conventions.
- Plan management with trial, grace, and invoice periods
- Standalone features with many-to-many plan relationships
- Per-plan feature values (e.g. Basic gets 5 users, Pro gets 50)
- Feature-based usage tracking with automatic resets
- Polymorphic subscriptions — attach to any model
- Grace period support — subscriptions stay active during grace window
- Lifecycle events — hook into created, canceled, renewed, plan changed
- Translatable plan/feature names and descriptions (via Spatie)
- Sortable plans and features (via Spatie)
- Configurable table names and swappable models
- Route middleware for feature gating
- Artisan command to prune expired subscriptions
| Package | PHP | Laravel |
|---|---|---|
2.x |
8.3, 8.4 |
11.x, 12.x, 13.x |
All combinations in the matrix above are exercised in CI on every push and pull request.
composer require crumbls/subscriptionsPublish config and migrations (optional):
php artisan vendor:publish --tag=subscriptions-config
php artisan vendor:publish --tag=subscriptions-migrationsRun migrations:
php artisan migrateMigrations autoload by default. Set autoload_migrations to false in config/subscriptions.php to disable this.
plans features
├── id ├── id
├── slug ├── slug
├── name (json) ├── name (json)
├── description (json) ├── description (json)
├── price ├── resettable_period
├── signup_fee ├── resettable_interval
├── currency ├── sort_order
├── invoice_period └── timestamps + soft deletes
├── invoice_interval
├── trial_period plan_features (pivot)
├── trial_interval ├── id
├── grace_period ├── plan_id → plans
├── grace_interval ├── feature_id → features
├── is_active ├── value
├── active_subscribers_limit├── sort_order
├── sort_order └── timestamps
└── timestamps + soft deletes
plan_subscriptions plan_subscription_usage
├── id ├── id
├── subscriber_id ├── subscription_id → plan_subscriptions
├── subscriber_type ├── feature_id → features
├── plan_id → plans ├── used
├── slug ├── valid_until
├── name (json) └── timestamps + soft deletes
├── description (json)
├── trial_ends_at
├── starts_at
├── ends_at
├── cancels_at
├── canceled_at
└── timestamps + soft deletes
Features are standalone entities. The value (limit) is set per-plan on the plan_features pivot. This lets a single "Users" feature have different limits across plans.
use Crumbls\Subscriptions\Traits\HasPlanSubscriptions;
class Tenant extends Model
{
use HasPlanSubscriptions;
}Works on any Eloquent model — User, Tenant, Team, Organization, etc.
use Crumbls\Subscriptions\Models\Plan;
$plan = Plan::create([
'name' => 'Pro',
'description' => 'Pro plan',
'price' => 9.99,
'signup_fee' => 1.99,
'invoice_period' => 1,
'invoice_interval' => 'month',
'trial_period' => 15,
'trial_interval' => 'day',
'grace_period' => 7,
'grace_interval' => 'day',
'currency' => 'USD',
]);Intervals accept: hour, day, week, month, year.
Features are standalone — create them once, attach to many plans:
use Crumbls\Subscriptions\Models\Feature;
$users = Feature::create([
'name' => 'Users',
'slug' => 'users',
'resettable_period' => 0, // never resets — running count
]);
$apiCalls = Feature::create([
'name' => 'API Requests',
'slug' => 'api-requests',
'resettable_period' => 1,
'resettable_interval' => 'month', // resets monthly
]);
$ssl = Feature::create([
'name' => 'SSL',
'slug' => 'ssl',
]);The value is set per-plan on the pivot — this is how different plans get different limits:
// Basic: 5 users, 100 API calls, no SSL
$basic->features()->attach($users, ['value' => '5']);
$basic->features()->attach($apiCalls, ['value' => '100']);
// Pro: 50 users, 10000 API calls, SSL enabled
$pro->features()->attach($users, ['value' => '50']);
$pro->features()->attach($apiCalls, ['value' => '10000']);
$pro->features()->attach($ssl, ['value' => 'true']);
// Enterprise: unlimited users, unlimited API calls, SSL enabled
$enterprise->features()->attach($users, ['value' => '999999']);
$enterprise->features()->attach($apiCalls, ['value' => '999999']);
$enterprise->features()->attach($ssl, ['value' => 'true']);Every model ships with a factory for testing and seeding:
use Crumbls\Subscriptions\Models\Plan;
use Crumbls\Subscriptions\Models\Feature;
use Crumbls\Subscriptions\Models\PlanSubscription;
Plan::factory()->create(); // a generic paid plan
Plan::factory()->free()->create(); // price = 0
Plan::factory()->paid()->withTrial(14)->create(); // 14-day trial
Plan::factory()->withGrace(7)->create(); // 7-day grace window
Plan::factory()->limitedTo(100)->create(); // capped at 100 subscribers
Plan::factory()->inactive()->create(); // is_active = false
Feature::factory()->resettableMonthly()->create();
Feature::factory()->resettableDaily()->create();
PlanSubscription::factory()
->for($user, 'subscriber')
->ended()
->canceled()
->create();$tenant->subscribe('main', $plan);
// Or the longer-named original:
$tenant->newPlanSubscription('main', $plan);
// Both take an optional start date:
$tenant->subscribe('main', $plan, now()->addDay());Subscription slugs are unique per subscriber, not globally, so both a User and a Tenant can hold a main subscription at the same time.
$tenant->subscribedTo($plan->id); // bool
$tenant->hasActiveSubscription(); // any active sub?
$subscription = $tenant->planSubscription('main');
$subscription = $tenant->currentSubscription(); // most recent active
$subscription->active(); // true if not ended, or on trial/grace
$subscription->onTrial(); // currently in trial period
$subscription->onGracePeriod(); // ended but within grace window
$subscription->canceled(); // has been canceled
$subscription->ended(); // period has expired
$subscription->inactive(); // opposite of active
$subscription->pendingCancellation(); // canceled but not yet ended
$subscription->daysUntilEnd(); // int or null
$subscription->daysUntilTrialEnd(); // int or null$subscription->recordFeatureUsage('api-requests');
$subscription->recordFeatureUsage('api-requests', 5); // add 5
$subscription->recordFeatureUsage('api-requests', 10, false); // set to 10
$subscription->reduceFeatureUsage('api-requests', 3);
$subscription->canUseFeature('api-requests'); // bool — checks used < value
$subscription->getFeatureUsage('api-requests'); // int — current usage
$subscription->getFeatureRemainings('api-requests'); // int — remaining quota
$subscription->getFeatureValue('api-requests'); // raw value from pivotIf you call recordFeatureUsage with a slug that isn't attached to the subscription's plan, it throws Crumbls\Subscriptions\Exceptions\UnknownFeatureException — the featureSlug and plan are exposed as readonly properties on the exception.
// When adding a user to a tenant
$subscription = $tenant->currentSubscription();
if (!$subscription->canUseFeature('users')) {
throw new \Exception('User limit reached for your plan.');
}
$subscription->recordFeatureUsage('users');
// When removing a user
$subscription->reduceFeatureUsage('users');$subscription->changePlan($newPlan); // switch plans (resets usage if billing cycle changes)
$subscription->renew(); // start a new period
$subscription->cancel(); // cancel at end of current period
$subscription->cancel(immediately: true); // cancel now
$subscription->reactivate(); // undo pending cancellationuse Crumbls\Subscriptions\Models\PlanSubscription;
PlanSubscription::findActive()->get();
PlanSubscription::findEndingPeriod(7)->get(); // ending within 7 days
PlanSubscription::findEndedPeriod()->get();
PlanSubscription::findEndingTrial(3)->get(); // trial ending within 3 days
PlanSubscription::findEndedTrial()->get();
PlanSubscription::ofSubscriber($tenant)->get();
PlanSubscription::byPlanId($plan->id)->get();
Plan::active()->get();
Plan::inactive()->get();
Plan::free()->get();
Plan::paid()->get();| Event | Fired when |
|---|---|
SubscriptionCreated |
A new subscription is created |
SubscriptionCanceled |
A subscription is canceled (includes $immediate flag) |
SubscriptionRenewed |
A subscription is renewed |
SubscriptionPlanChanged |
A subscription switches plans (includes $oldPlan and $newPlan) |
use Crumbls\Subscriptions\Events\SubscriptionCreated;
class SendWelcomeEmail
{
public function handle(SubscriptionCreated $event): void
{
$event->subscription->subscriber->notify(/* ... */);
}
}These events are plain dispatchable events — they do not implement ShouldBroadcast out of the box. If you want a broadcast version, extend the event in your application and add implements ShouldBroadcast there.
Gate routes by feature or subscription:
// Must have the "api-requests" feature available
Route::middleware('can-use-feature:api-requests')->group(/* ... */);
// Check a specific subscription by slug
Route::middleware('can-use-feature:api-requests,pro')->group(/* ... */);
// Must have any active subscription
Route::middleware('subscribed')->group(/* ... */);
// Must be subscribed to a specific plan slug
Route::middleware('subscribed:pro')->group(/* ... */);php artisan subscriptions:prune # soft-deletes subs ended more than 30 days ago
php artisan subscriptions:prune --days=90 # custom threshold
php artisan subscriptions:prune --force # skip confirmationSchedule it:
// routes/console.php or bootstrap/app.php
Schedule::command('subscriptions:prune --force')->daily();Publish the config to customize table names or swap model classes:
// config/subscriptions.php
return [
'autoload_migrations' => true,
'tables' => [
'plans' => 'plans',
'features' => 'features',
'plan_features' => 'plan_features',
'plan_subscriptions' => 'plan_subscriptions',
'plan_subscription_usage' => 'plan_subscription_usage',
],
'models' => [
'plan' => \Crumbls\Subscriptions\Models\Plan::class,
'feature' => \Crumbls\Subscriptions\Models\Feature::class,
'plan_feature' => \Crumbls\Subscriptions\Models\PlanFeature::class,
'plan_subscription' => \Crumbls\Subscriptions\Models\PlanSubscription::class,
'plan_subscription_usage' => \Crumbls\Subscriptions\Models\PlanSubscriptionUsage::class,
],
];Every model is resolved through config. Extend the base model and update the config:
namespace App\Models;
use Crumbls\Subscriptions\Models\Plan as BasePlan;
class Plan extends BasePlan
{
public function cancel(bool $immediately = false): static
{
// Cancel recurring payment with your provider
$this->subscriber->paymentProvider()->cancelSubscription($this);
return parent::cancel($immediately);
}
}// config/subscriptions.php
'models' => [
'plan' => \App\Models\Plan::class,
],All relationships, scopes, traits, middleware, and the prune command resolve models through config — your custom models are used everywhere automatically.
| Model | Description |
|---|---|
Plan |
A subscription plan with pricing, billing cycle, trial/grace periods |
Feature |
A standalone feature (e.g. "Users", "API Calls", "SSL") |
PlanFeature |
Pivot model linking features to plans with a value per plan |
PlanSubscription |
A subscriber's subscription to a plan |
PlanSubscriptionUsage |
Tracks how much of a feature a subscription has consumed |
- Payments are out of scope. This package handles plan/subscription logic only. Integrate with Stripe, Paddle, etc. separately via events or model overrides.
- Translatable fields:
nameanddescriptionon plans, features, and subscriptions are stored as JSON and support multiple locales via spatie/laravel-translatable. - Soft deletes: Plans, features, subscriptions, and usage all use soft deletes. The prune command only soft-deletes; use
forceDelete()if you need permanent removal. - Features are standalone: Create a feature once, attach it to multiple plans with different values. This avoids duplicating feature definitions across plans.
This package started as a modern reboot of rinvex/laravel-subscriptions, so the mental model is similar but a few things are worth knowing if you're porting an existing app:
- Namespace:
Crumbls\Subscriptions\...(wasRinvex\Subscriptions\...). - Config key:
subscriptions(wasrinvex.subscriptions). - Subscribing: prefer
$user->subscribe('main', $plan)—newPlanSubscription()also works. Rinvex's mix ofnewSubscription()/subscribe()is unified. - Features are standalone. Rinvex defines features inline on the plan; here a
Featureis its own row, and the value (limit) lives on theplan_featurespivot. That's how you get "Basic: 5 users, Pro: 50 users" from a singleusersfeature. - No
rinvex:*artisan commands. Use plainphp artisan migrateandphp artisan vendor:publish. - No model-level validation. Use Form Requests in your own app.
- Events. Four Laravel events (
SubscriptionCreated,SubscriptionCanceled,SubscriptionRenewed,SubscriptionPlanChanged) in place of rinvex's trait-based hooks.
See UPGRADING.md for version-to-version migration steps. Breaking changes are called out in CHANGELOG.md.
See CONTRIBUTING.md.
MIT