karpovigorok/subkit
SubKit is a lightweight subscription toolkit for Laravel. Define plans and features, attach subscribers, run trials, and gate access with simple middleware — all through a clean, framework-native API with no billing provider lock-in. Ships with migrations, models, and Blade helpers.
Prerequisites (verify against the README before installing):
laravel/cashier (Stripe) installed and configured.env: STRIPE_KEY, STRIPE_SECRET, STRIPE_WEBHOOK_SECRET, and CASHIER_CURRENCYMinimal install sequence:
composer require karpovigorok/subkit
php artisan vendor:publish --tag="subkit-config"
php artisan vendor:publish --tag="subkit-migrations"
php artisan migrate
php artisan subkit:install # if provided; otherwise register the plugin manually
Register the Filament plugin in your panel provider (usually app/Providers/Filament/AdminPanelProvider.php):
->plugin(\Karpovigorok\Subkit\SubkitPlugin::make())
Add the Billable trait (Cashier's, or the package's variant) to your User model, then point your webhook at the package route or Cashier's /stripe/webhook.
Where to look first, in order:
config/subkit.php — plan definitions, currency, billable model, feature list. This is the whole contract of the package.plans / subscriptions / features schema before writing any business logic on top of it.src/Filament/ — that's where plan CRUD lives, and where you'll extend admin UX.First use case to ship: a plan in the Filament admin → assign it to a user → redirect to Stripe Checkout → feature flag flips on after webhook. Do this end-to-end with stripe listen --forward-to localhost:8000/stripe/webhook before touching anything else. If that loop works, the rest of the package is mostly UI sugar.
Plans as data, not code. Keep stripe_price_id, interval, amount, and a JSON features column in the plans table and edit them through Filament. Resist hardcoding prices in config — the value of the package is that a non-developer can create a plan. Reference plans by slug (Plan::whereSlug('pro')->first()), never by ID.
Feature gating. The typical pattern is a hasFeature(string $feature): bool on the billable model or a Subkit facade, then a thin middleware:
Route::middleware(['auth', 'subscribed:pro'])->group(...);
In Blade, prefer @if(auth()->user()->subscribedTo('pro')) over embedding feature strings inline — wrap it in your own helper so the package call sites are greppable if you ever swap it out.
Checkout and portal flows. Keep two named routes — one that creates a Checkout Session for a plan slug, one that redirects to the Stripe Billing Portal. Both should accept a return_url. Don't build custom card forms; let Stripe host them.
Upgrade / downgrade. Handle plan swaps via Cashier's swap() with proration, and after a swap always redirect through the portal or a confirmation screen rather than assuming the webhook has already landed. Treat the webhook as the source of truth for the final state, and the request as optimistic UI.
Webhook handling. If the package ships its own webhook endpoint, you still own idempotency. Store processed event.ids in a table (or cache) and skip duplicates. For anything the package ignores — invoice.payment_failed, customer.subscription.trial_will_end — add your own listeners rather than forking. Cashier's event classes are the clean extension point.
Admin vs. tenant panels. If you have a customer-facing Filament panel, do not expose SubkitPlugin there wholesale. Either scope the resources behind a policy, or register only the read-only "current subscription" widget on the tenant panel and keep plan CRUD on the admin panel.
Trials and coupons. Store trial length on the plan, not in Stripe's product config, so it's editable in Filament. Coupons are best passed as a Checkout Session parameter (allow_promotion_codes => true) instead of managing them in the DB.
Testing. Use Cashier::fake() for unit tests of gates and middleware. For the checkout redirect and webhook loop, run against Stripe test mode with a dedicated test clock — mocking Stripe's subscription lifecycle reliably is more work than it's worth.
stripe listen and copy the ephemeral secret into .env on each session, or your features will never activate. Add a log line in your webhook handler that fires on every received event so you can see immediately whether Stripe is reaching you.subscriptions-ish migrations. Publish Cashier first, then Subkit, and diff the files — a duplicated subscriptions table will fail on migrate with a confusing "table already exists" if you're not looking. If the package owns the schema, remove Cashier's migration before running.App\Models\User. If you're billing a Team/Tenant, set subkit.billable_model (or the equivalent) before migrating — the foreign keys are baked into the published migrations.filament/filament and the package to compatible constraints in composer.json; a composer update on Filament alone can break the panel with a fatal error in the plugin register().PlanPolicy can expose plan creation to any panel user. Write the policies explicitly.hasFeature() called per-row in a Filament table will re-query the subscription each time. Cache the active subscription on the model instance ($user->relationLoaded('subscription') / a memoized property) or eager-load it in the table query.active, trialing, past_due, incomplete, and canceled are all distinct. Only active + trialing should unlock features by default; treat past_due as grace-period-with-banner and incomplete as locked. If the package collapses this to a boolean, wrap it.proration_behavior explicitly (create_prorations vs always_invoice) — the default changed across Stripe API versions.BillingService) so the day you swap to raw Cashier you change one file. Publish the views and the config rather than relying on package defaults, and don't subclass package classes — compose around them.CASHIER_CURRENCY and the package's currency config consistently at install time; changing it later doesn't reprice existing Stripe prices.stripe_id set? (2) does the local subscriptions row exist and match the Stripe subscription ID? (3) did the webhook 200 or 400? (4) is the feature slug spelled identically in the plan JSON and the gate call? That last one accounts for a surprising share of "bugs."How can I help you explore Laravel packages today?