lucasdotvin/laravel-soulbscription
Laravel Soulbscription is an elegant subscription and feature management package for Laravel. Define plans and features, attach subscriptions to any model, and control consumable quotas, renewals, and access through a simple, expressive API built for real-world billing flows.
Soulbscription is a subscriptions + feature-metering package: you define Plans, attach Features to them (optionally with a charges limit), and let billable models subscribe, consume quota, renew, and expire. It is not a payment package — you wire Stripe/Paddle/etc. yourself and use Soulbscription as the entitlement layer.
Minimal setup:
composer require lucasdotvin/laravel-soulbscription
php artisan vendor:publish --tag="soulbscription:config"
php artisan vendor:publish --tag="soulbscription:migrations"
php artisan migrate
Add the trait to your billable model:
use LucasDotVin\Soulbscription\Concerns\HasSubscriptions;
class User extends Authenticatable
{
use HasSubscriptions;
}
Seed a catalog (plans and features are just rows — treat them as data, not code):
$plan = Plan::create([
'name' => 'Pro',
'periodicity' => 'monthly', // null = no periodicity (lifetime/one-off)
'price' => 29.00,
]);
$feature = Feature::create([
'name' => 'API Calls',
'slug' => 'api-calls',
'consumable' => true,
]);
$plan->features()->attach($feature, ['charges' => 10000]);
First realistic use case — gate a metered endpoint:
$user->subscribeTo('pro');
if (! $user->canConsume('api-calls', 1)) {
abort(429, 'Quota exhausted');
}
$user->consume('api-calls', 1);
Where to look first, in this order:
config/soulbscription.php — model and table bindings (plan, feature, subscription, plan_feature). These are swappable; know them before you write queries.started_at, expires_at, canceled_at, suppressed_at, renewal, plus the feature_consumptions table).php artisan list soulbscription and schedule the renewal and expiration commands. Nothing renews or expires unless the scheduler runs.// routes/console.php (Laravel 11+) or app/Console/Kernel.php
Schedule::command('soulbscription:renew')->daily();
Schedule::command('soulbscription:expire')->daily();
Separate entitlement checks from consumption. Check before doing the work, consume after it succeeds, and unconsume on failure. Consuming first and refunding in a catch leaves drift when the process dies mid-request.
public function handle(Request $request)
{
$user = $request->user();
if (! $user->canConsume('api-calls')) {
throw new QuotaExceededException();
}
try {
$result = $this->doExpensiveThing();
$user->consume('api-calls');
return $result;
} catch (Throwable $e) {
// nothing consumed yet — no refund needed
throw $e;
}
}
Named subscriptions for multi-tenant or parallel entitlements. A user can hold more than one subscription (personal + per-team). Pass a name and always disambiguate at the call site rather than relying on "the current subscription":
$user->subscribeTo($teamPlan, 'team-'.$team->id);
$user->subscription('team-'.$team->id)->consume('seats', 1);
Upgrades and downgrades. switchTo() moves the subscription to a new plan; proration and refunds are your responsibility. Always record an audit row (old plan, new plan, who triggered it, whether money moved) — the package keeps state, not history.
DB::transaction(function () use ($user, $newPlan) {
$old = $user->subscription()->plan;
$user->switchTo($newPlan);
PlanChange::create([
'user_id' => $user->id,
'from' => $old->id,
'to' => $newPlan->id,
'source' => 'self-serve',
]);
});
Cancel vs suppress. Cancel stops the subscription at period end (canceled_at); suppress is a pause that keeps the record but suspends renewal/entitlement (suppressed_at). Model dunning as suppress-then-resume rather than cancel-then-recreate, so you keep consumption history and the original started_at.
Lifetime / one-off plans. A plan with no periodicity is the escape hatch for lifetime deals and credit packs. These should have expires_at as null and never be touched by the renewal job. This is exactly what 4.3.2 fixes: prior versions could stamp a preset expiration onto a no-periodicity subscription during renewal, silently killing lifetime access at the next scheduler run. If you sell lifetime plans, pin >=4.3.2 and add a regression test:
$plan = Plan::factory()->create(['periodicity' => null]);
$user->subscribeTo($plan);
$this->travel(2)->years();
Artisan::call('soulbscription:renew');
$this->assertNull($user->subscription()->fresh()->expires_at);
$this->assertTrue($user->subscription()->isActive());
Events are your integration seam. Renewal, expiration, cancellation, plan change, and feature consumption all fire events. Queue listeners and use them for webhooks, analytics, and "your plan expired" email — do not do that work inline inside the scheduler command, or a slow mailer will stall renewals for every other customer.
Testing. RefreshDatabase + factories for Plan/Feature, and travel()/Carbon::setTestNow() to fast-forward to renewal boundaries. Test the scheduled commands, not just the model methods — the commands are where the interesting edge cases live.
expires_at has passed is still "active" in the database until the command runs. Gate on computed state (expires_at, suppressed_at) in your hot paths rather than trusting a status column, and monitor that the scheduled commands actually ran — a silently failing cron is the most common production incident with this package.canConsume is a read, not a reservation. Two concurrent requests can both pass the check and both consume, overshooting the limit. For tight quotas, wrap check-and-consume in a transaction with lockForUpdate() on the subscription, or front it with Redis/atomic counters.consume() resolves to a default subscription. Be explicit everywhere — a quota leaking from the team subscription into the personal one is a subtle, hard-to-spot bug.charges semantics matter. A consumable feature with a numeric charges is a quota; a non-consumable feature is a boolean entitlement (use canConsume as a feature flag). Decide per feature and encode it in the seeder so it doesn't drift between environments. Unlimited vs zero should be represented distinctly — verify in the source how a null charge is treated before you ship a "unlimited" tier.config/soulbscription.php lets you point at your own model classes. If you extend the shipped models, keep the table names, publish the migrations first, and be aware that adding columns in a future release means re-publishing/diffing migrations — pin the version and review the diff on upgrade.now() in the app timezone. Mixed UTC/local storage around midnight causes off-by-one-day renewals that only show up in production.switchTo, cancel, suppress, and on the renewal/expiration events. Stale entitlements are usually a cache bug, not a package bug.subscriptions and feature_consumptions tables — every symptom (wrong quota, double consumption, unexpected expiry) is visible there. $user->subscription()->features gives you the pivot with charges, which is the fastest way to confirm whether the plan configuration or the code is wrong.expires_at values and null them out where appropriate.How can I help you explore Laravel packages today?