lucasdotdev/laravel-soulbscription
Soulbscription is a Laravel package for subscription and feature management. Define plans, subscribe models, track feature quotas and consumption, and handle recurring or prepaid access through a clean, expressive API.
Install and publish
composer require lucasdotvin/laravel-soulbscription
php artisan vendor:publish --tag="soulbscription"
php artisan migrate
Decide your model strategy before writing feature code. The package ships Plan, Feature, Subscription and Consumption models, but config/soulbscription.php lets you point at your own subclasses and rename tables. Swapping models after you already have production rows means rewriting foreign keys, so make this call on day one.
Wire the traits. Add the subscription trait and the feature-consumption trait to your billable model (usually User) and confirm the subscriber relation resolves — every package table keys off the subscriber, not the user table.
First use case — quota-limited feature. Model a "10 posts per month" plan:
Feature with a stable slug (posts).Plan with periodicity_type (month) and periodicity (1), then attach the feature to the plan with a charges value on the pivot (10).$user->subscribeTo($plan).canConsume('posts', 1), do the work, then consume('posts', 1).Where to look first: the published migrations (they show exactly how periods, consumptions and the plan/feature pivot are shaped), then the config file (models, tables, any listeners), then the traits the package adds to your user model — the trait is the real API surface and the fastest way to see the exact method signatures.
Treat plans as seed data, not user-editable records. Define them in a seeder with a stable lookup key, attach features and charges there, and keep admin UI to "choose a plan" rather than "compose a plan". Charges live on the plan↔feature pivot, so the same feature can cost 10 on one plan and unlimited on another — that flexibility is great until support has to reason about half-edited plans.
Gate with policies, consume after success. Put canConsume() in a Gate/FormRequest/policy so the 402/403 response is consistent, but only call consume() after the side effect actually succeeded:
DB::transaction(function () use ($user, $post) {
$user->consume('posts', 1);
$post->publish();
});
Wrapping both in one transaction means a failed publish doesn't burn quota.
Distinguish increment from sync. Use consume() for "the user did a thing" and setConsumedQuantity() for mirroring an external number (seats used, storage bytes, metered billing). Idempotent syncs keep you out of the "why does this user have 47 consumptions today" hole.
Build a small read model for balance. balance() / featureBalance('posts') are fine per-request, but pricing pages and dashboards that iterate features for every row produce N+1s. Cache a per-user balance snapshot, invalidate it on the package's consumption and subscription events.
Hook lifecycle via events, not model observers. Listen for subscription started / renewed / cancelled / switched to fire welcome mail, provision resources, or push usage to an external billing system. Observers on the package models will also fire for internal package writes, which is rarely what you want.
Schedule renewals explicitly. Renewal is not automatic magic you can ignore — schedule the package's renewal command (check php artisan list for the exact command in your version) and make sure the scheduler runs in the timezone your periods mean. Add a monitoring check that no subscription sits past expired_at without being renewed or marked expired.
Model the cancel UX up front. "Cancel at period end" vs "cancel now" is a product decision the package won't make for you: keep the subscription record until expired_at and gate on active status rather than deleting anything.
Test with time travel. Carbon::setTestNow() plus travel through a period boundary is the only reliable way to assert renewal, expiration and consumption-reset behaviour. Add a test for a plan with no periodicity (see gotchas).
4.3.2 is relevant if you sell lifetime/one-off plans. The hotfix ("preset expiration in renewals for plans with no periodicity") addresses renewals writing an expiration onto plans that have no periodicity. If you upgraded to 4.3.0/4.3.1 and sell non-expiring plans, audit any subscription that went through renewal in that window — expired_at may be set where it should be null — and pin >=4.3.2.
Null periodicity means "never expires", not "expired". Any of your own status helpers must treat expired_at === null as active. This is the single easiest way to accidentally lock out lifetime customers, and it's the class of bug 4.3.2 fixes.
Consumption is scoped to the subscription/period, not the user forever. When debugging "quota didn't reset", look at the subscription boundary first (period start/end, timezone) before suspecting the consumption records. Renewing creates/rolls the window the balance is computed against.
Concurrent consumption overshoots. Two parallel requests both pass canConsume() and both consume. For hard quotas, wrap check+consume in a transaction with a row lock on the subscription (or a unique/length constraint at the DB level), and accept that a soft quota is fine for most SaaS gating.
Charges are configuration, not data. Editing the pivot charges changes the computed balance immediately for the current period. If you need historical pricing, snapshot the charges onto the subscription/consumption at consume time rather than reading the pivot later.
Changing models or table names in config post-launch breaks relations silently — queries start hitting an empty/new table rather than erroring loudly. Do it in one migration with data copy, or not at all.
Debugging recipe: inspect subscriptions (started/expired, plan id), consumptions (feature id, quantity, subscription id) and the plan↔feature pivot (charges) in that order. Most "balance is wrong" reports are a missing pivot row or an expired subscription, not a package bug.
Extension points worth knowing: config for models/tables, the lifecycle events for integrations, and your own Plan/Feature subclasses for domain-specific accessors. Avoid editing vendor migrations — republish and adjust at install time if you need extra columns, and prefer additive migrations afterwards.
How can I help you explore Laravel packages today?