Skip to content

Start building your next app or website — with AI, right in your browser.

Visit devdojo.com
Docs
Billing

Apps (Coming Soon) 26 / 32

Billing

Drop-in subscriptions, plans, and checkout for Laravel — backed by Stripe or Paddle — with plan-based feature limits, blade directives, middleware, webhooks, a Filament admin resource, and a visual setup page.

devdojo/billing owns the entire billing domain for a DevDojo app: plans, checkout, subscriptions, invoices, webhooks, and plan-based entitlements. It leans on your application only for the User model and its roles — everything else ships in the box. It is one of the feature packages bundled by foundation, and it also works standalone in any Laravel app.

Overview

The package models the billing domain as three cooperating records and two traits on your User:

  • A Plan carries pricing (monthly_price, yearly_price, provider price IDs), a currency, JSON limits, and an associated Spatie Role.
  • When a user checks out, a Subscription is created and the user is granted the plan's role (syncRoles([]) then assignRole()). On cancellation the user is returned to the configured default role.
  • The HasSubscriptions trait adds subscription helpers to your User; HasPlanFeatures adds feature-limit helpers.

Two providers are supported — Stripe (hosted Checkout Session + customer portal) and Paddle (Paddle.js overlay). The provider is chosen per-app via config, so the same UI and API work against either.

Roles are required. Plans map to roles, so your User must use Spatie's HasRoles trait and you must seed roles under the web guard. This is what makes @subscriber, the subscribed middleware, and role-gated features work.

Requirements

  • PHP 8.2+ and Laravel 10 – 13.
  • spatie/laravel-permission (v6 – v8), stripe/stripe-php, Livewire 3/4, Volt, and Folio are all pulled in automatically by Composer.
  • The bundled checkout and subscription-update UI renders Filament notification and modal components, so plan on having Filament installed if you use the shipped pages; the models, traits, middleware, and webhooks work without it.
  • Your app must provide a few destination pages the flows redirect to: /subscription/welcome (post-checkout success) and /settings/subscription (checkout cancel / plan-switch landing), plus a route named settings.subscription for the customer-portal return (configurable via portal_return_route).

Installation

composer require devdojo/billing

Publish the config and migrations, then migrate:

php artisan vendor:publish --tag=billing:config
php artisan vendor:publish --tag=billing:migrations
php artisan migrate

Migrations are publish-only (not auto-loaded), so the plans and subscriptions tables live in your app's database/migrations and are yours to edit. Add your provider credentials to .env:

BILLING_PROVIDER=stripe          # stripe | paddle

STRIPE_PUBLISHABLE_KEY=pk_test_...
STRIPE_SECRET_KEY=sk_test_...
STRIPE_WEBHOOK_SECRET=whsec_...

# Only needed when BILLING_PROVIDER=paddle
PADDLE_VENDOR_ID=
PADDLE_API_KEY=
PADDLE_ENV=sandbox
PADDLE_PUBLIC_KEY=
PADDLE_WEBHOOK_SECRET=

Configuration

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 legacy wave.* keys — making the package a drop-in replacement in existing Wave apps. The settings.php file is where you point at your User model and default role:

return [
    'billing_provider'    => env('BILLING_PROVIDER', 'stripe'),
    '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' => []],
];

Wiring your User model

Add both billing traits and Spatie's HasRoles to your User:

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 isn't discoverable from config('auth.providers.users.model'), set BILLING_USER_MODEL explicitly.

Plans and roles

Each plan is tied to a role. Create your roles and 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']);
$pro = Role::firstOrCreate(['name' => 'pro', 'guard_name' => 'web']);

Plan::create([
    'name' => 'Pro',
    'features' => ['Unlimited projects', 'Priority support'],
    'monthly_price' => '19',
    'yearly_price' => '190',
    'monthly_price_id' => 'price_123', // Stripe / Paddle price ID
    'yearly_price_id' => 'price_456',
    'currency' => '$',
    'active' => true,
    'role_id' => $pro->id,
    'limits' => ['sites' => 25, 'api_keys' => 10],
]);

The checkout experience

The package registers the /billing/* area as Laravel Folio pages:

URL Route name Purpose
/billing billing Redirects to /billing/checkout
/billing/checkout billing.checkout Branded checkout: logo, active plans, monthly/yearly toggle, subscribe buttons (auth)
/billing/invoices billing.invoices The signed-in user's invoices (auth)
/billing/setup billing.setup Gated visual configuration screen (view-billing-setup)

The checkout page embeds the billing.checkout Livewire component, which you can drop into any of your own pages alongside billing.update:

@notsubscriber
    <livewire:billing.checkout />
@endnotsubscriber

@subscriber
    <livewire:billing.update />
@endsubscriber

On Stripe, Subscribe opens a Stripe Checkout Session; the checkout.session.completed webhook then creates the Subscription and assigns the role. On Paddle, it opens the Paddle.js overlay and verifies the transaction client-side.

The visual setup page

/billing/setup is an auth-style configuration screen with three tabs — Appearance (accent color, logo height), Language (checkout copy), and Payment Keys (provider + credentials). Changes are written straight to config/devdojo/billing/*.php via devdojo/config-writer and take effect on the next request. It's protected by the view-billing-setup middleware group: visible in your local environment, or to any user passing the viewBillingSetup Gate.

Gate::define('viewBillingSetup', fn ($user) => $user->isAdmin());

Checking subscription status

The HasSubscriptions trait adds these methods to your User (subscriber checks are cached for 5 minutes):

$user->subscriber();            // bool — has an active subscription?
$user->subscribedToPlan('Pro'); // bool — active sub to a plan by name
$user->onTrial();               // bool
$user->plan();                  // Plan — the current plan
$user->planInterval();          // 'Monthly' | 'Yearly'
$user->latestSubscription();    // Subscription|null
$user->switchPlans($plan);      // sync roles to a new plan's role
$user->invoices();              // array — invoices from Stripe/Paddle
$user->clearUserCache();        // bust the subscriber/role caches

Bust the cache after out-of-band changes. If you change a subscription outside the normal checkout/webhook flow, call $user->clearUserCache() so the 5-minute subscriber cache doesn't serve a stale answer.

Blade directives

Directive True when
@subscriber / @endsubscriber User has an active subscription
@notsubscriber / @endnotsubscriber User has no active subscription
@subscribed('Pro') / @endsubscribed Subscribed to a specific plan by name
@canUseFeature('sites') / @endcanUseFeature Under the plan limit for a feature
@featureNearLimit('sites') / @endfeatureNearLimit At or above 80% of the limit
@featureLimitReached('sites') / @endfeatureLimitReached At or over the limit

Protecting routes

Use the subscribed middleware alias to require an active subscription (admins pass through). Non-subscribers are redirected to the billing route:

Route::middleware(['auth', 'subscribed'])->group(function () {
    Route::get('/app', AppController::class);
});

Plan-based feature limits

Define numeric limits per plan in the limits JSON column, then tell the package how to count usage. Create a config/limits.php in your app (the package doesn't publish one — top-level limits.* keys are read first, falling back to devdojo.billing.settings.limits):

// config/limits.php (you create this file)
return [
    'admin_bypass' => true,
    'defaults' => ['sites' => 1], // for users without a plan
    'features' => [
        'sites' => ['model' => \App\Models\Sites\Site::class, 'column' => 'user_id'],
    ],
];
Limit value Meaning
positive int the limit
0 feature disabled
-1 explicitly unlimited
key absent / null unlimited

The HasPlanFeatures trait then exposes featureLimit(), featureUsage(), canUseFeature($feature, $amount = 1), featureRemaining(), featureLimitReached(), featureUsagePercent(), featureNearLimit(), and allFeatureLimits(). Guard an action with:

abort_unless($user->canUseFeature('sites'), 403, 'Upgrade to create more sites.');

Webhooks and the customer portal

The webhook endpoints keep stable paths so they're safe to register in your provider dashboards:

Provider Endpoint Route name Events handled
Stripe POST /webhook/stripe billing.webhook.stripe checkout.session.completed, checkout.session.async_payment_succeeded, customer.subscription.updated, customer.subscription.deleted
Paddle POST /webhook/paddle billing.webhook.paddle subscription.canceled

Stripe verifies the signature with STRIPE_WEBHOOK_SECRET; Paddle verifies the Paddle-Signature header via the paddle-webhook-signature middleware. The Stripe customer portal is available at GET /stripe/portal (stripe.portal) and returns the user to config('devdojo.billing.settings.portal_return_route'). Paddle invoice PDFs are served from GET /settings/invoices/{invoice} (wave.paddle.invoice). Locally, forward Stripe events with:

stripe listen --forward-to localhost:8000/webhook/stripe

Filament admin

If you use Filament, register the plugin in your panel to get a Plans resource at /admin/plans for managing plan details, pricing IDs, status, sort order, feature limits, and the associated role. Filament is an optional (suggested) dependency:

use Devdojo\Billing\Filament\BillingPlugin;

return $panel->plugin(BillingPlugin::make());

The Plan and Subscription models

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->getLimit('sites'); // int | null
$plan->hasLimit('sites'); // bool

$subscription->user;         // the billable user (belongsTo)
$subscription->plan;         // the plan
$subscription->cancel();     // mark cancelled + reset user to the default role

Subscriptions 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.

The scheduled command

subscriptions:cancel-expired cancels active subscriptions whose ends_at has passed and clears each user's cache. Schedule it hourly:

use Illuminate\Support\Facades\Schedule;

Schedule::command('subscriptions:cancel-expired')->hourly();

How it's wired

BillingServiceProvider merges the four config files, then in boot() always registers the users morph alias (also relied on by accounts), the publish tags, and the console command — regardless of the feature toggle. Since migrations are published into your app, the schema survives being toggled off. It then gates the rest of the package on the foundation flag:

if (! config('foundation.features.billing', true)) {
    return; // routes, checkout UI, Livewire components, directives, Folio pages
}

When billing is disabled its routes, checkout UI, blade directives, and Filament resource aren't registered — but your published 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.

Extension points

Publish tag Publishes to
billing:config config/devdojo/billing/*
billing:migrations database/migrations
billing:assets public/billing
billing:components resources/views/components/billing/elements

Views live under the billing:: namespace and the x-billing.button / x-billing.billing_cycle_toggle components are registered automatically. Publish billing:components to override the checkout elements, or build your own UI directly on top of the models and the HasSubscriptions / HasPlanFeatures traits. When a platform screen exposes a reusable entitlement pattern, prefer adding it to this package rather than the app — see the foundation strategy.

© 2026 DevDojo Edit this page