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), acurrency, JSONlimits, and an associated Spatie Role. - When a user checks out, a Subscription is created and the user is granted the plan's role (
syncRoles([])thenassignRole()). On cancellation the user is returned to the configured default role. - The
HasSubscriptionstrait adds subscription helpers to yourUser;HasPlanFeaturesadds 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 namedsettings.subscriptionfor the customer-portal return (configurable viaportal_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.