nafiswatsiq/subbase
Advanced subscription management for Laravel with a Filament admin panel. Build plans with multi-currency pricing and features, manage full subscription lifecycles, add promo codes and discounts, plus optional Spatie roles and custom models. Built on laravelcm/laravel-subscriptions.
1. Install and wire it up (5 commands):
composer require nafiswatsiq/subbase
php artisan subbase:install # publishes config, migrations, views, translations
php artisan migrate
2. Give your billable model the trait — Subbase delegates all subscription logic to laravelcm/laravel-subscriptions, so the trait comes from there:
use Laravelcm\Subscriptions\Traits\HasPlanSubscriptions;
class User extends Authenticatable
{
use HasPlanSubscriptions;
}
3. Register the Filament plugin in app/Providers/Filament/AdminPanelProvider.php:
->plugin(SubbasePlugin::make())
The service provider itself is auto-discovered; you only touch bootstrap/providers.php if you've disabled discovery.
Where to look first, in this order:
| File | Why |
|---|---|
config/subbase.php |
Currency defaults, locale→currency map, table names, model bindings, optional permission strings. This is the spine of the package. |
vendor/nafiswatsiq/subbase/src/Models/Plan.php |
getPriceForCurrency, getPriceForLocale, hasCurrencyPrice, setPriceForCurrency — your pricing API. |
vendor/nafiswatsiq/subbase/src/Helpers/PlanPriceHelper.php |
The single place where price + discount resolution is composed. |
docs/discount-feature.md + upstream laravelcm/laravel-subscriptions README |
Subbase only documents its additions; the actual subscribe/cancel/swap/feature-usage API lives upstream. Budget an hour reading the upstream docs — it's not optional. |
First use case to prove the install: seed two plans with a USD and an IDR price, create one percentage discount code, then drop <x-subbase::plan-list /> into a Blade view. If you see currency-correct prices, an interval tab switcher, and a promo badge, everything downstream works.
$plan = Plan::active()->where('featured', true)->first();
$price = $plan->getPriceForLocale(app()->getLocale()); // en-US → USD via config map
$price = $plan->getPriceForCurrency('EUR'); // explicit override
$plan->setPriceForCurrency('EUR', 15.99)->save();
Treat getPriceForLocale() as the display default and getPriceForCurrency() as the checkout truth — the user's chosen currency at checkout should always be explicit and validated with hasCurrencyPrice() before you charge.
The discount engine is deliberately inert — it validates and calculates, it does not apply anything for you. The pattern that works:
$discount = Discount::findByCode($request->code);
abort_unless($discount && $discount->isValid(), 422, 'Invalid promo code');
abort_unless($discount->appliesToPlan($plan->getKey()), 422, 'Code not valid for this plan');
$result = PlanPriceHelper::resolveWithDiscounts($plan, $currency);
// ['original' => 99.00, 'discounted' => 49.50, 'hasDiscount' => true, ...]
// ...charge $result['discounted'] via your gateway...
DB::transaction(function () use ($discount, $subscription) {
$discount->markUsed(); // you call this manually
$subscription->discounts()->attach($discount->id); // discount_subscription pivot
});
Only call markUsed() after payment confirmation, inside a transaction with the pivot attach. Never at code-entry time.
Because Subbase wraps upstream, use the upstream fluent API rather than the Filament resources:
$user->newSubscription('main', $plan)->create($paymentMethodId);
$user->subscription('main')->cancel(); // or ->cancelNow()
$user->subscription('main')->swap($newPlan);
$user->subscription('main')->renew();
$user->subscription('main')->canUseFeature('api-calls');
$user->subscription('main')->recordFeatureUsage('api-calls');
Reserve the Filament panel for administration (creating plans, features, discounts, viewing subscribers) and do all customer-facing state transitions in your own controllers/services.
Bind them in config and extend the base class:
'models' => [
'plan' => App\Models\Plan::class,
'subscription' => App\Models\Subscription::class,
'subscription_usage' => App\Models\SubscriptionUsage::class,
],
class Plan extends \Nafiswatsiq\Subbase\Models\Plan {}
Do this before you write business logic that type-hints the package models — retrofitting is painful because the Filament resources, PlanPriceHelper, and discount relations all resolve through config.
<x-subbase::plan-list subscribe-route="checkout.show" />
Route::get('subscribe/{plan}', CheckoutController::class)->name('checkout.show');
Publish the view (--tag="subbase-views") as soon as a designer touches it; the default component is opinionated Tailwind and its interval tabs, featured-plan emphasis, and discount badge are all in one Blade file.
This package is early-stage — pin aggressively. Six stars, zero dependents, and a requirements line listing Laravel 13.7+ / Filament 5.0+. Verify those constraints against your actual composer.json before committing; a constraint mismatch here will block install or, worse, resolve to an untested combination. Use "nafiswatsiq/subbase": "1.5.*" rather than ^1.0.
subbase:install is not idempotent-upgrading. It (and vendor:publish) silently skip existing files. On version bumps you must use:
php artisan subbase:upgrade --migrations # additive only, safe
php artisan subbase:upgrade --config # selective overwrite
php artisan subbase:upgrade --force # clobbers config AND views
Diff your config and your published views against the vendor copies before running --force. There is no merge; your customizations are gone.
Config caching bites on model bindings. After editing config/subbase.php (especially the models array) you must php artisan config:clear. A stale cached config with a half-migrated model binding produces confusing "class not found" or, worse, silently writes to the base table instead of your custom one.
Migration collisions with upstream. Subbase publishes migrations atop laravelcm/laravel-subscriptions. Check the published table names in config/subbase.php against any existing subscription tables in your database before migrate on a production-adjacent environment.
Currency fallbacks are silent. getPriceForLocale() returns the base price when no currency row exists. That means a missing EUR price renders as a plausible USD number rather than an error. Guard checkout paths with hasCurrencyPrice() explicitly, and add a test that asserts every active plan has a price for every currency in your getAvailableCurrencies() union.
Discount validation is passive. isValid() checks active flag, date window, and max_uses — but max_uses is only correct if markUsed() is called exactly once. There's no atomic counter or uniqueness constraint visible in the docs; wrap redemption in a transaction and re-fetch the discount row (lockForUpdate()) if you expect concurrent promo usage.
Timezone drift on starts_at / expires_at. These are stored as timestamps; schedule them in the app timezone and verify against config('app.timezone'), or a "starts midnight" campaign will go live at the wrong hour.
Permissions: three-state behavior. With spatie/laravel-permission installed, Filament resource access follows your configured strings. Without it, everything is open. With the permission value left empty but Spatie present, it falls back to Shield-style names (ViewAny:Plan, ViewAny:Subscription). Know which state you're in — "empty string" is not the same as "no permission required."
Best extension points, ranked:
plan-list.blade.php — full control over frontend display.PlanPriceHelper — wrap it in your own service to add tax, proration, or tiered pricing rather than patching vendor code.--tag="subbase-translations") — override plan.php, subscription.php, discount.php per locale.Debugging toolkit:
config('subbase.models.plan'); // confirm the binding is live
Plan::first()->getAvailableCurrencies(); // what's actually seeded
Discount::findByCode('X')?->isValid(); // why a code is rejected
php artisan tinker against Discount and Plan resolves roughly 80% of "why isn't this showing up" reports faster than reading the Blade component.
Write your own tests. The package ships no test suite you can lean on. Cover: price resolution per currency, discount validity windows, markUsed() call counts, and one end-to-end subscribe→use-feature→cancel path. Given the maturity level, those tests are your upgrade safety net.
How can I help you explore Laravel packages today?