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 Plans Laravel Package

lacodix/laravel-plans

Manage SaaS plans and addons with subscriptions, billing intervals, and countable or unlimited features like credits and tokens that auto-reset. Consume feature usage across plans in order, with translations, sorting, and metadata. No billing included.

View on GitHub
Deep Wiki
Context7

Getting Started

1. Install and publish

composer require lacodix/laravel-plans
php artisan vendor:publish   # pick the package's migrations (+ config if offered) from the list
php artisan migrate

The service provider is auto-discovered, so there's nothing to register. Confirm your app runs php artisan about cleanly afterward — a bad migration publish is the most common first-day failure.

2. Look at the schema before you write a line of code

Read the published migration files first. They tell you more than the README about what is actually stored: the plans table (slug, translatable name, price, active, billing_interval, billing_period, meta), the features table, the plan↔feature pivot (per-plan value, resettable_period, resettable_interval), and the polymorphic subscriptions table plus its consumption bookkeeping. Everything else in the package is convenience methods over these rows.

3. Attach the trait

use Lacodix\LaravelPlans\Models\Traits\HasSubscriptions;

class User extends Authenticatable {
    use HasSubscriptions;
}

Use it on whatever the paying entity is — User, Team, Tenant. The relation is polymorphic, so you can attach it to more than one model, but decide now which one owns subscriptions, because consumption checks and subscribe() calls need a single canonical subject.

4. First working vertical slice

The fastest way to understand the package is one plan, one feature, one subscriber, one check:

$plan = Plan::create([
    'slug' => 'pro',
    'name' => 'Pro',
    'price' => 50.0,
    'active' => true,
    'billing_interval' => Interval::MONTH,
    'billing_period' => 1,
]);

$tokens = Feature::create(['slug' => 'tokens', 'name' => 'Tokens']);

$plan->features()->attach($tokens, [
    'value' => 1000,
    'resettable_period' => 1,
    'resettable_interval' => Interval::MONTH,
]);

$user->subscribe($plan, 'main');

// read the entitlement
$user->feature('tokens');   // remaining/available amount

Do this in a seeder or a Tinker session before touching any UI. If the read returns what you expect, your mental model of plans → features → subscriptions is correct and the rest is CRUD.

5. Where to read next

The linked docs site (lacodix.de/docs/laravel-plans) is the real reference — the README stops at the quickstart. Prioritise the pages on consumption, renewals, and events, in that order. Skip the pricing/meta pages until you have billing on the roadmap.


Implementation Patterns

Seed the catalog, don't hand-create plans in production

Treat plans and features as reference data: a seeder (or a plan:sync artisan command you write) that upserts by slug with updateOrCreate. That makes plans reproducible across local/staging/prod, reviewable in git, and re-runnable after a schema change. Admins editing plans in a UI is a later feature; without the seeder you can't rebuild an environment.

Use slug slots for plan roles, not plan identity

$user->subscribe($plan, 'main') and $user->subscribe($addon, 'addon') — the second argument is a slot. Subscribing to a new plan in the main slot replaces the old one, which is exactly the plan-change semantics you want. Encode your roles (main, addon, trial) as constants and never let user input reach that parameter.

// Upgrade = same slot, new plan
$user->subscribe($proPlan, Slot::MAIN);

Handle proration yourself (or defer it): the package swaps the subscription, it doesn't refund a partial period. effectiveMeta() and the effective-price helper are the seams for computing what the customer "already paid for" when you generate the credit note.

Model entitlement checks as a single service, not scattered calls

Wrap feature access in one class so the rest of the app doesn't import package models:

final class Entitlements
{
    public function __construct(private Model $subject) {}

    public function allows(string $feature): bool
    {
        return (bool) $this->subject->feature($feature);
    }

    public function consume(string $feature, int $amount = 1): bool
    {
        return DB::transaction(fn () => $this->subject->consume($feature, $amount) !== false);
    }
}

Then Entitlements::allows('api-access') is your gate, and swapping the entitlement layer later is one file.

Countable vs uncountable — pick deliberately

  • Uncountable (API access, SSO, priority support): attach with a boolean-ish value, check truthiness. No reset logic, no consumption.
  • Countable (tokens, credits, seats): attach with value, set resettable_period/resettable_interval, consume on use.

Don't model a boolean as a large number "just in case" — you lose the intent and the reset semantics get noisy.

Consumption across multiple plans is ordered — design the order

Usage is split across the subscriber's plans in subscription order. So a main plan with 1000 tokens and an addon with 500 will drain main first, then the addon. That ordering is a product decision, not an implementation detail: if you let users reorder, you change which bucket they burn. Set the order at subscribe time and expose it read-only in the UI ("used from your Pro plan first").

Keep consumption inside a transaction

Two concurrent requests both reading "900 remaining" will both consume. Wrap the check-consume pair in DB::transaction() and, if the package doesn't lock for you, re-read with lockForUpdate() on the subscription row. This is the single most likely production bug in a credit-based app.

Renewals must be driven by the scheduler

Nothing auto-renews on its own — you call renew() (or the aggregated renewal routine) from a scheduled command:

// routes/console.php
Schedule::command('plans:renew')->hourly(); // exact command name per docs

Write that command to be idempotent and to log each subscription it renews. Test it by freezing time with travel() and asserting the period advanced exactly once when run twice.

Wire billing as an event listener, not a fork

Listen for the subscription created/renewed/cancelled events and push to Stripe/Paddle/your invoicer:

class CreateInvoiceForRenewal implements ShouldHandleEventsAfterCommit
{
    public function handle($event): void
    {
        // map $event->subscription->plan->meta['external_price_id'] → provider
    }
}

ShouldHandleEventsAfterCommit matters: these events fire inside the renewal transaction, and firing an HTTP call to a payment provider mid-transaction is how you get double invoices and phantom subscriptions.

Use meta for the billing handoff, not for app state

plan.meta is the right home for currency, stripe_price_id, tier_rank — data a billing system or a pricing page needs. It is not the right home for per-user state; that belongs on the subscription's meta or your own tables. effectiveMeta() (v1.4+) merges plan and subscription meta, which is the accessor you want when a subscription-level override should win.

Testing pattern

RefreshDatabase plus a factory that builds a plan + features, then assertions on feature() before/after consume() and after travel(1 month) + renew(). If you fork or patch the package, mirror those tests upstream — you'll want them when you upgrade.


Gotchas and Tips

It does not do billing. No invoices, no payment capture, no dunning, no tax. price is a display number. Budget for the full billing integration separately; the package only gives you the events to hang it on.

The subscriptions table will collide with Laravel Cashier. Both packages want a table called subscriptions. Resolution order matters: either rename this package's table in its config/migration before the first migrate, or don't use Cashier at all. Discovering this after migrating to production means a painful rename migration with live subscriptions. Decide before you install.

Slugs are your API; names are display. name is a spatie/laravel-translatable array. Never branch on name, never where('name', ...). Code references slugs, views render names via the locale accessor. If you add a slug rename later, write a data migration — subscriptions reference plan IDs, but your code references slugs.

Deactivate plans, don't delete them. active = false keeps existing subscribers intact while removing the plan from the signup list. Deleting a plan with live subscriptions cascades or orphans data depending on FK setup — check the migrations and never assume cascadeOnDelete was the intent.

Unlimited is a sentinel, not a number. Arithmetic on an unlimited value (remaining - requested, %, comparisons like < 100) will silently misbehave. Branch on the unlimited check first, then on the numeric value, and encapsulate it so no caller ever compares raw values.

Resets only happen on the paths the package controls. A feature with a resettable_interval won't magically reset at midnight — the reset is tied to renewal/consumption flow. If you're scheduling resets independently, verify against the docs and test with frozen time, otherwise you'll ship "credits never refill" and only hear about it from customers.

Renewals are not idempotent by default. Running the renewal command twice, or overlapping scheduler runs, can double-advance a period. Guard with a renewed_at-style check or withoutOverlapping() on the scheduled command.

Consumption ordering is global state. Inserting a new addon subscription changes where usage flows from that moment onward. If support comps someone a plan, they've also changed which bucket their credits drain from. Log subscription creation with its order so you can explain a user's usage history later.

Polymorphic subscriber — pick one, then enforce it. If both User and Team use HasSubscriptions, a feature check on the wrong subject returns zero and looks like a bug in the package. Gate all entitlement access behind a service that resolves the current billing subject from the request context.

Pin the version and isolate it. Seven stars, zero dependents, one maintainer, sub-2.0 API. composer require lacodix/laravel-plans:^1.5 with a caret constraint is fine, but: keep the Entitlements wrapper so you can swap implementations, avoid extending package models unless the config exposes model swapping (then extend the configured class, never touch vendor), and check the CHANGELOG before every minor bump — v1.4 and v1.5 added methods, so signature drift is plausible in 1.x.

Debugging tip: read the rows. When feature('tokens') returns something surprising, drop to SQL. Inspect the plan↔feature pivot value, the subscription's period columns, and any consumption ledger rows. Nine times out of ten it's a missing pivot, a zero value, or a subscription whose period already lapsed because the scheduler command isn't running.

Don't call subscribe() from a controller without an idempotency guard. Double-submitted checkout forms create duplicate subscriptions in different slots, and now the user has two main-ish plans. Either use a unique constraint you define yourself, or funnel subscription creation through a service with a request token.

Publish the config and commit it. Package defaults drift between versions; an explicit config/plans.php in git is how you notice a changed default before it hits production.

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.
adachsoft/changelog-linter
adachsoft/collection
payerurl/telegrambot-crypto-payment-instant-payout
dskripchenko/laravel-hybrid-search
riorizkyrainey/lara-ced
mehedimi/static-vel
zharp/zharp
nonsapiens/bigquery-model-sync
lgu-bayambang-ict-office/lgu-laravel-development-standards
andydefer/laravel-support
se7enxweb/flysystem
lampwick/laravel
jeslage/statamic-emojis
micahdshackelford/rmap-laravel
draftsmaninc/draftsman
square-bit/laravel-volition
himito/laravel-mailman
htinlynn/genius-helper
maco-studios/hephaestus
adachsoft/php-code-style