Drop-in subscriptions, plans, and checkout for Laravel — backed by Stripe or Paddle. Ships models, a branded checkout experience, a visual setup page, a Filament admin resource, plan‑based feature limits, blade directives, middleware, and webhook handling.
devdojo/billing is one of the feature packages bundled by
devdojo/foundation, but it works perfectly
well standalone in any Laravel app.
- Requirements
- How it works
- Installation
- Configuration
- Wiring your User model
- Plans & roles
- The checkout experience
- The setup page
- Checking subscription status
- Blade directives
- Protecting routes
- Plan-based feature limits
- Webhooks
- Filament admin
- The Subscription & Plan models
- The scheduled command
- Customizing views & assets
- Using with DevDojo Foundation
- Configuration reference
- FAQ / troubleshooting
| Requirement | Notes |
|---|---|
PHP ^8.2 |
|
Laravel ^10 / ^11 / ^12 |
|
spatie/laravel-permission ^6 |
Plans map to roles; your User must use HasRoles. |
livewire/livewire ^3 + livewire/volt |
Powers the checkout & setup components. |
laravel/folio ^1 |
Powers the /billing/* pages. |
devdojo/config-writer |
Persists changes made on the setup page. |
stripe/stripe-php ^16 / ^17 |
Stripe API client. |
filament/filament ^4 (optional) |
Only needed for the bundled Plan admin resource. |
The checkout/update views also use a couple of Filament Blade components (modals). If you intend to render the bundled checkout UI, install Filament; otherwise you can build your own UI on top of the models and traits.
The package owns the billing domain and leans on your application for the User and roles:
┌──────────────────────────────────────────────────────────────┐
│ devdojo/billing │
│ │
│ Plan ──belongsTo──▶ Role (Spatie, provided by your app) │
│ │ │
│ └─hasMany─▶ Subscription ──belongsTo──▶ User (your app) │
│ │
│ Traits added to your User: │
│ • HasSubscriptions (subscriber(), plan(), invoices()…) │
│ • HasPlanFeatures (canUseFeature(), featureLimit()…) │
│ │
│ Providers: Stripe + Paddle • Webhooks • /billing/* UI │
│ • Filament PlanResource • Blade directives • Command │
└──────────────────────────────────────────────────────────────┘
- A Plan has pricing IDs (Stripe price / Paddle price), prices, a currency, feature limits, and an associated role.
- When a user checks out, a Subscription is created and the user is granted the plan's role. When the subscription is cancelled, the user is returned to the default role.
- Your
Usermodel gains subscription helpers and feature-limit helpers via two traits.
composer require devdojo/billingPublish the migrations and config, then migrate:
php artisan vendor:publish --tag=billing:migrations
php artisan vendor:publish --tag=billing:config
php artisan migrateMigrations are publish-only (not auto-loaded) so the
plansandsubscriptionstables live in your app'sdatabase/migrationsand are yours to edit.
Add your credentials to .env:
BILLING_PROVIDER=stripe # stripe | paddle
# Stripe
STRIPE_PUBLISHABLE_KEY=pk_test_...
STRIPE_SECRET_KEY=sk_test_...
STRIPE_WEBHOOK_SECRET=whsec_...
# Paddle (only if BILLING_PROVIDER=paddle)
PADDLE_VENDOR_ID=
PADDLE_API_KEY=
PADDLE_ENV=sandbox # sandbox | production
PADDLE_PUBLIC_KEY= # client-side token
PADDLE_WEBHOOK_SECRET=Finally, wire your User model and (optionally) register the Filament admin.
Publishing billing:config writes four files under config/devdojo/billing/:
| File | Holds | Config key |
|---|---|---|
keys.php |
Stripe & Paddle credentials | devdojo.billing.keys |
settings.php |
Provider, host models, default role, portal return route, limit defaults | devdojo.billing.settings |
style.php |
Accent color + logo height for the checkout/setup UI | devdojo.billing.style |
language.php |
Editable copy for the checkout page | devdojo.billing.language |
All configuration is read through Devdojo\Billing\Support\Config, which prefers the
devdojo.billing.* keys and falls back to the legacy wave.* keys — so the package is a
drop-in replacement in existing Wave apps.
See the full configuration reference below.
Add the two traits to your User model. It must also use Spatie's HasRoles.
use Devdojo\Billing\Traits\HasSubscriptions;
use Devdojo\Billing\Traits\HasPlanFeatures;
use Spatie\Permission\Traits\HasRoles;
class User extends Authenticatable
{
use HasRoles, HasSubscriptions, HasPlanFeatures;
}If your User class isn't discoverable from config('auth.providers.users.model'), set it
explicitly:
BILLING_USER_MODEL="App\\Models\\User"
BILLING_DEFAULT_ROLE=registeredEach plan is tied to a role. Create your roles (Spatie) and a default role, then create plans that point at them.
use Spatie\Permission\Models\Role;
use Devdojo\Billing\Models\Plan;
Role::firstOrCreate(['name' => 'registered', 'guard_name' => 'web']); // default role
$pro = Role::firstOrCreate(['name' => 'pro', 'guard_name' => 'web']);
Plan::create([
'name' => 'Pro',
'description' => 'Everything you need to scale.',
'features' => ['Unlimited projects', 'Priority support'],
'monthly_price' => '19',
'yearly_price' => '190',
'monthly_price_id' => 'price_123', // Stripe price ID or Paddle price ID
'yearly_price_id' => 'price_456',
'currency' => '$',
'active' => true,
'role_id' => $pro->id,
'limits' => ['projects' => 25, 'api_keys' => 10],
]);When a user subscribes to this plan they are granted the pro role
(syncRoles([]) + assignRole('pro')). When the subscription ends they are returned to
config('devdojo.billing.settings.default_role') (default registered).
The package registers a cohesive /billing/* area (Laravel Folio pages):
| URL | What |
|---|---|
/billing |
Redirects to /billing/checkout. Named route: billing. |
/billing/checkout |
Branded checkout — your logo, all active plans, a monthly/yearly toggle, and subscribe buttons. |
/billing/invoices |
The signed-in user's invoices. |
/billing/setup |
Gated visual configuration screen (see below). |
The checkout page embeds the billing.checkout Livewire component. You can drop that same
component into any of your own pages:
{{-- Show the plan picker to non-subscribers --}}
@notsubscriber
<livewire:billing.checkout />
@endnotsubscriber
{{-- Show "manage subscription" to subscribers --}}
@subscriber
<livewire:billing.update />
@endsubscriber- Stripe — clicking Subscribe creates a Stripe Checkout Session (with the plan,
cycle, and user in the metadata) and redirects to Stripe. On success Stripe sends a
checkout.session.completedwebhook which creates theSubscriptionand assigns the plan's role. The user is returned to/subscription/welcome(success) or/settings/subscription(cancel) — provide those pages in your app. - Paddle — clicking Subscribe opens the Paddle.js overlay. On
checkout.completedthe component verifies the transaction, creates theSubscription, assigns the role, and resolves the Paddle subscription id.
Visit /billing/setup for an auth-style visual configuration screen with three tabs:
- Appearance — accent color and logo height.
- Language — the checkout heading, description, sidebar copy, and an optional banner.
- Payment Keys — choose the provider and enter your Stripe / Paddle credentials.
Changes are written straight to config/devdojo/billing/*.php via devdojo/config-writer
and take effect on the next request.
The page is protected by the view-billing-setup middleware: it is visible in your local
environment, or to any user passing the viewBillingSetup Gate:
// app/Providers/AppServiceProvider.php
Gate::define('viewBillingSetup', fn ($user) => $user->isAdmin());The HasSubscriptions trait adds these methods to your User:
$user->subscriber(); // bool — has an active subscription? (cached 5 min)
$user->subscribedToPlan('Pro'); // bool — active sub to a plan by name? (cached)
$user->onTrial(); // bool — on trial and not yet subscribed?
$user->plan(); // Plan — the current plan
$user->planInterval(); // 'Monthly' | 'Yearly'
$user->latestSubscription(); // Subscription|null — most recent active
$user->subscription; // HasOne — active subscription relation
$user->subscriptions; // HasMany — all of the user's subscriptions
$user->switchPlans($plan); // sync roles to a new plan's role
$user->invoices(); // array — invoices from Stripe/Paddle
$user->clearUserCache(); // bust the subscriber/role cachesSubscriber checks are cached for 5 minutes. Call
$user->clearUserCache()after you change a user's subscription outside the normal flow.
@subscriber ... @endsubscriber {{-- has an active subscription --}}
@notsubscriber ... @endnotsubscriber {{-- has no active subscription --}}
@subscribed('Pro') ... @endsubscribed {{-- subscribed to a specific plan --}}
@canUseFeature('projects') ... @endcanUseFeature {{-- under the plan limit --}}
@featureNearLimit('projects') ... @endfeatureNearLimit {{-- ≥ 80% of the limit --}}
@featureLimitReached('projects')... @endfeatureLimitReached {{-- at/over the limit --}}Use the subscribed middleware to require an active subscription (admins pass through):
Route::middleware(['auth', 'subscribed'])->group(function () {
Route::get('/app', AppController::class);
});Non-subscribers are redirected to the billing route (/billing/checkout).
Define numeric limits per plan in the limits JSON column:
'limits' => [
'projects' => 25, // allow 25
'api_keys' => 0, // disabled
'seats' => -1, // explicitly unlimited
],| Value | Meaning |
|---|---|
| positive int | the limit |
0 |
feature disabled |
-1 |
explicitly unlimited |
key absent / null |
unlimited |
Tell the package how to count usage for each feature in config/limits.php:
// config/limits.php
return [
'admin_bypass' => true, // admins ignore all limits
'defaults' => [ // limits for users without a plan
'projects' => 1,
],
'features' => [
'projects' => ['model' => \App\Models\Project::class, 'column' => 'user_id'],
'api_keys' => ['model' => \App\Models\ApiKey::class, 'column' => 'user_id'],
],
];Then use the HasPlanFeatures helpers:
$user->featureLimit('projects'); // 25 | 0 | null (unlimited)
$user->featureUsage('projects'); // current count
$user->canUseFeature('projects'); // bool — room for 1 more?
$user->canUseFeature('projects', 5); // bool — room for 5 more?
$user->featureRemaining('projects'); // int | null
$user->featureLimitReached('projects'); // bool
$user->featureUsagePercent('projects'); // 0–100 | null
$user->featureNearLimit('projects'); // ≥ 80% by default
$user->allFeatureLimits(); // array of the plan's limitsGuard an action:
abort_unless($user->canUseFeature('projects'), 403, 'Upgrade to create more projects.');The package exposes two webhook endpoints (paths are stable so they're safe to register in your provider dashboards):
| Provider | Endpoint | Events handled |
|---|---|---|
| Stripe | POST /webhook/stripe |
checkout.session.completed, checkout.session.async_payment_succeeded, customer.subscription.updated, customer.subscription.deleted |
| Paddle | POST /webhook/paddle |
subscription.canceled |
Stripe verifies the signature with STRIPE_WEBHOOK_SECRET. Locally:
stripe listen --forward-to localhost:8000/webhook/stripe
# put the printed whsec_... into STRIPE_WEBHOOK_SECRETPaddle verifies the Paddle-Signature header (PADDLE_WEBHOOK_SECRET) via the
paddle-webhook-signature middleware.
The Stripe customer portal is available at GET /stripe/portal (named stripe.portal); it
returns the user to config('devdojo.billing.settings.portal_return_route')
(default settings.subscription).
If you use Filament, register the plugin in your panel to get a Plans resource at
/admin/plans:
use Devdojo\Billing\Filament\BillingPlugin;
public function panel(Panel $panel): Panel
{
return $panel
// ...
->plugin(BillingPlugin::make());
}The resource manages plan details, pricing IDs, status/sort order, feature limits, and the associated role. Filament is an optional dependency — the package works without it.
use Devdojo\Billing\Models\Plan;
use Devdojo\Billing\Models\Subscription;Plan
Plan::getActivePlans(); // active plans, ordered, with roles (cached 30 min)
Plan::getByName('Pro'); // a plan by name (cached)
Plan::clearCache(); // bust the plan caches
$plan->role; // the associated Spatie Role
$plan->subscriptions; // subscriptions on this plan
$plan->getLimit('projects'); // int | null
$plan->hasLimit('projects'); // boolSubscription
$subscription->user; // the billable user
$subscription->plan; // the plan
$subscription->cancel(); // mark cancelled + reset the user to the default roleSubscriptions are polymorphic (billable_*) and store vendor identifiers
(vendor_slug, vendor_customer_id, vendor_subscription_id, …), the cycle
(month/year/onetime), status, and trial/end dates.
subscriptions:cancel-expired cancels active subscriptions whose ends_at has passed:
// routes/console.php
use Illuminate\Support\Facades\Schedule;
Schedule::command('subscriptions:cancel-expired')->hourly();Publish the bundled checkout/update components and reusable elements to override them:
php artisan vendor:publish --tag=billing:components # → resources/views/components/billing/elements
php artisan vendor:publish --tag=billing:assets # → public/billingThe package's views live under the billing:: namespace
(e.g. billing::livewire.billing.checkout). The x-billing.button and
x-billing.billing_cycle_toggle Blade components are registered automatically.
When the devdojo/foundation metapackage is
installed, billing self-gates on its feature flag:
// config/foundation.php
'features' => [
'billing' => true, // flip to false (or toggle at /foundation/setup) to disable
],When billing is disabled its routes, checkout UI, Livewire components, blade directives, and
Filament resource are not registered — but its migrations and the users morph alias stay
active, so toggling it off and back on is instant and lossless.
Standalone (no Foundation present), the flag is absent and billing defaults to on.
return [
'billing_provider' => env('BILLING_PROVIDER', 'stripe'), // stripe | paddle
'user_model' => env('BILLING_USER_MODEL'), // null → auth.providers.users.model
'role_model' => \Spatie\Permission\Models\Role::class,
'default_role' => env('BILLING_DEFAULT_ROLE', 'registered'),
'portal_return_route' => 'settings.subscription',
'limits' => [
'admin_bypass' => true,
'defaults' => [],
'features' => [],
],
];return [
'stripe' => [
'publishable_key' => env('STRIPE_PUBLISHABLE_KEY'),
'secret_key' => env('STRIPE_SECRET_KEY'),
'webhook_secret' => env('STRIPE_WEBHOOK_SECRET'),
],
'paddle' => [
'vendor_id' => env('PADDLE_VENDOR_ID'),
'api_key' => env('PADDLE_API_KEY'),
'env' => env('PADDLE_ENV'),
'public_key' => env('PADDLE_PUBLIC_KEY'),
'webhook_secret' => env('PADDLE_WEBHOOK_SECRET'),
],
];return [
'color' => 'blue', // black|white|red|green|blue|yellow|orange|pink|purple
'logo_height' => '36',
];return [
'subscriptions' => [
'header' => 'Subscribe to a Plan Below',
'description' => 'Select a plan below.',
'sidebar_description' => 'Welcome to the checkout page for your SaaS product.',
'notification' => '',
],
];| Tag | Publishes to |
|---|---|
billing:config |
config/devdojo/billing/* |
billing:migrations |
database/migrations |
billing:assets |
public/billing |
billing:components |
resources/views/components/billing/elements |
Admin users get a 403 / lose access after extracting billing.
Plans store the user's role via Spatie's morph alias. The package registers only the
users morph alias (matching Spatie role pivots) and leaves it on even when billing is
toggled off. Make sure your User uses HasRoles and that roles were seeded under the
web guard.
route('settings.subscription') not found from the Stripe portal.
The portal return URL is configurable — set
config('devdojo.billing.settings.portal_return_route') to a route your app defines.
The checkout view errors about a Filament component.
The bundled checkout/update views use Filament modal components. Install
filament/filament, or render your own UI on top of the models/traits.
Where do plans / subscriptions tables come from?
They are publish-only migrations — run
php artisan vendor:publish --tag=billing:migrations && php artisan migrate.
MIT © DevDojo