Weave Code
Weaver
Helps Laravel developers discover, compare, and choose open-source packages. See popularity, security, maintainers, and scores at a glance to make better decisions.
Feedback
Share your thoughts, report bugs, or suggest improvements.
Subject
Message

Laravel Soulbscription Laravel Package

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.

View on GitHub
Deep Wiki
Context7

Getting Started

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:

  1. config/soulbscription.php — model and table bindings (plan, feature, subscription, plan_feature). These are swappable; know them before you write queries.
  2. The published migrations — this is the real schema documentation (started_at, expires_at, canceled_at, suppressed_at, renewal, plus the feature_consumptions table).
  3. Scheduled commands — run 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();

Implementation Patterns

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.

Gotchas and Tips

  • Nothing happens on its own. Expiry and renewal are scheduler-driven, not observer-driven. A subscription whose 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.
  • Know which subscription you are consuming against. With named or multiple subscriptions, 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.
  • Model swapping is a one-way door. 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.
  • Timezone consistency. Period math is done against now() in the app timezone. Mixed UTC/local storage around midnight causes off-by-one-day renewals that only show up in production.
  • Cache invalidation. If you cache a computed "what can this user do" payload, clear it on switchTo, cancel, suppress, and on the renewal/expiration events. Stale entitlements are usually a cache bug, not a package bug.
  • Debugging playbook. Go straight to the 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.
  • 4.3.2 upgrade note. Hotfix-only, no API changes, no migration — safe drop-in from 4.3.1. If you use no-periodicity plans, treat it as urgent rather than routine: the failure mode is silent loss of paid lifetime access, and it has already happened for anyone who ran the renewal job on an affected version. After upgrading, audit existing lifetime subscribers for non-null expires_at values and null them out where appropriate.
Weaver

How can I help you explore Laravel packages today?

Conversation history is not saved when not logged in.
Prompt
Add packages to context
No packages found.
shitoudev/permissionmanager
johannesclimacus/laravel-tui
hocvt/seotools
vitalybaev/sypexgeo
amphibee/themosis-ignition
gito-me/fast-excel
frankyso/laravel-ipaymu
geolize-studio/laravel-chile
boomerangui/chat
jinas/dhivatar
store-doc/laravel-storage-driver
plus-it/laravel-translation-manager
diephp/perhaps
diephp/telegraph
osmaviation/spreadsheet
microsoft/fluentui-system-icons
mmccook/php-json-canonicalization-scheme
middlewares/firewall
qubiqx/montapacking
nyoncode/laravel-package-console