graystackit/laravel-mollie-billing
Batteries-included Mollie billing for Laravel with VAT/OSS compliance, VIES validation, wallet-based metered billing, coupons, trials, scheduled plan changes, webhooks/mandates, admin panel, and a Livewire 4 customer portal—built around a Billable contract.
Batteries-included Mollie billing for Laravel: VAT/OSS compliance, wallet-based metered billing, coupons, scheduled plan changes, trial flow, admin panel and a Livewire 4 customer portal.
A batteries-included Mollie billing layer for Laravel that wraps mollie/laravel-mollie ^4 and adds VAT/OSS compliance, wallet-based metered billing, a coupon engine, scheduled plan changes, an admin panel, and a Livewire 4 customer portal — all keyed off a Billable contract that lives on whichever model owns the subscription (typically your Organization, not your User).
mpociot/vat-calculator)bavix/laravel-wallet), with case-insensitive usage-type lookupspast_due stateSinglePayment, Recurring, Credits, TrialExtension, AccessGrant@planFeature Blade directive and billing.feature middleware/promotion/{token} URLslivewire/flux-pro — required for the customer portal, the checkout flow and the admin panel. Not pulled in by this package (commercial license needed); install it separately in your application: composer require livewire/flux-pro.composer require graystackit/laravel-mollie-billing
Publish the config and migrations:
php artisan vendor:publish --tag=mollie-billing-config # mollie-billing.php + mollie-billing-plans.php
php artisan vendor:publish --tag=mollie-billing-migrations
php artisan vendor:publish --tag=mollie-billing-views # optional: override Blade/Livewire views
php artisan vendor:publish --tag=billing-lang # optional: override translations
Edit config/mollie-billing.php and set the billable model:
'billable_model' => \App\Models\Organization::class,
'billable_key_type' => 'uuid', // 'uuid' | 'ulid' | 'int'
'user_key_type' => 'int', // 'uuid' | 'ulid' | 'int' — primary key type of your auth user model
Important:
billable_key_typeanduser_key_typemust be set before running migrations for the first time.billable_key_typecontrols the column type of every polymorphic foreign key that references your billable — includingbavix/laravel-wallet'swallets.holder_id,transactions.payable_id, andtransfers.{from,to}_id, which we rewrite from the defaultbiginttouuid/ulid.user_key_typecontrols columns that reference the auth user (e.g.billing_country_mismatches.resolved_by_user_id). Changing them later requires manually altering those columns.
Then run migrations:
php artisan migrate
The package ships server-rendered Blade/Livewire views but no compiled CSS or JS — it relies on your host app's Vite pipeline. The shipped layouts (portal, checkout, admin) call @vite(['resources/css/app.css', 'resources/js/app.js']), so your app must:
vite.config.js (the Laravel default).@source (Tailwind v4) or content (v3) entry to add.If you publish the views (--tag=mollie-billing-views) and Tailwind already scans resources/views/**, the published copies are picked up automatically — but the @source line for the vendor path is still needed for any unpublished views.
Verify your configuration before deploying — the package ships a validator that checks both mollie-billing.php and mollie-billing-plans.php for syntax errors, broken references (unknown feature_keys, allowed_addons, product group, …) and likely misconfigurations:
php artisan billing:check-config
See the Commands section below for the full list of issues it detects.
Add the HasBilling trait and implement the Billable contract on your billable model — typically a tenant or organization, not the User:
<?php
namespace App\Models;
use GraystackIT\MollieBilling\Concerns\HasBilling;
use GraystackIT\MollieBilling\Contracts\Billable;
use Illuminate\Database\Eloquent\Model;
class Organization extends Model implements Billable
{
use HasBilling;
public function getUsedBillingSeats(): int
{
return $this->users()->count();
}
}
Configure your environment:
BILLING_MOLLIE_KEY=test_xxxxxxxxxxxxxxxxxxxxxxxxxxxx
BILLING_BILLABLE_MODEL=App\Models\Organization
BILLING_BILLABLE_KEY_TYPE=uuid
BILLING_USER_KEY_TYPE=int
BILLING_CURRENCY=EUR
BILLING_MOLLIE_KEY is an alias for MOLLIE_KEY from mollie/laravel-mollie —
either works. The package propagates BILLING_MOLLIE_KEY into mollie.key so
your .env stays on the BILLING_* prefix.
Mount the package routes in routes/web.php. The three route groups serve different scopes and need different middleware:
use GraystackIT\MollieBilling\Facades\MollieBilling;
// Customer portal — needs auth + your tenant resolution middleware
Route::middleware(['web', 'auth', 'tenant'])->group(function () {
MollieBilling::routes();
});
// Checkout — needs auth but NOT tenant resolution (the checkout creates the tenant)
Route::middleware(['web', 'auth'])->group(function () {
MollieBilling::checkoutRoutes();
});
// Admin panel — auth only, no tenant scope. AuthorizeBillingAdmin runs inside the group.
Route::middleware(['web', 'auth'])->group(function () {
MollieBilling::adminRoutes();
});
The admin routes are auto-loaded by the service provider as well, so you only need to call adminRoutes() if you want them under a custom middleware stack.
If your app nests the portal behind a tenant parameter (e.g. prefix('{organization:slug}')), mount MollieBilling::routes() inside that group — do not apply your own ->name('tenant.') prefix around it, because the package's views call route('billing.*') by those exact names. Keep checkoutRoutes() outside the tenant group since no tenant exists yet at checkout time:
Route::middleware(['auth', 'tenant'])
->prefix('{organization:slug}')
->group(function () {
MollieBilling::routes();
});
// Checkout lives outside the tenant prefix — the billable is created during checkout
Route::middleware(['auth'])->group(function () {
MollieBilling::checkoutRoutes();
});
The package ships a PropagateRouteDefaults middleware that copies the active route's parameters into URL::defaults, so generated links inside the portal (e.g. route('billing.plan')) automatically carry the tenant slug — no app-side URL::defaults wiring required.
For contexts without an active HTTP request — queued notifications, background jobs, or services that run before tenant resolution — PropagateRouteDefaults cannot help. Register a global URL parameter resolver so the package can build correct URLs from any context:
use GraystackIT\MollieBilling\Contracts\Billable;
use GraystackIT\MollieBilling\Facades\MollieBilling;
MollieBilling::urlParametersUsing(
fn (?Billable $billable) => $billable
? ['organization' => $billable->slug]
: []
);
The $billable parameter is null in rare cases where no billable is available yet (e.g. some middleware checks). In those cases the closure should return [] or derive a fallback from auth()->user() or session state.
How the two mechanisms interact:
PropagateRouteDefaultscovers the request context (portal views, form submissions).urlParametersUsingcovers everything else (Mollie webhook URLs, redirect URLs sent to Mollie, queued mail, background jobs). They complement each other — both can be active simultaneously without conflict.
If your billable model needs custom logic beyond the global resolver, you can still override urlRouteParameters() on the model directly — the override takes precedence.
Tell the facade how to resolve the current billable for the authenticated user — usually in AppServiceProvider::boot():
use GraystackIT\MollieBilling\Facades\MollieBilling;
MollieBilling::resolveBillableUsing(fn () => auth()->user()?->currentOrganization);
MollieBilling::authUsing(fn () => auth()->check());
Checkout-route fallback: the checkout route is mounted outside any tenant prefix, so a typical
resolveBillableUsingclosure that reads from a tenant context returnsnullthere. When that happens the package falls back to looking up the billable from the request's query parameters, matching against the billable model'sgetRouteKeyName(). A returning customer hitting/billing/checkout?organization=acme-corptherefore re-uses the existing billable instead of being asked for the company details again. Reserved query keys (back,plan,interval,redirect,token) are skipped.
Customize config/mollie-billing-plans.php to define your plans, addons and feature keys.
The package ships a complete first-checkout flow — a multi-step Livewire wizard that collects billing details, lets the customer choose a plan, optional addons/seats, apply a coupon, and redirects to Mollie for payment.
Register three callbacks in your AppServiceProvider::boot():
use GraystackIT\MollieBilling\Facades\MollieBilling;
// How to create a billable (Organization, Team, …) from checkout form data:
MollieBilling::createBillableUsing(function (array $data) {
return Organization::create([
'name' => $data['name'],
'billing_street' => $data['billing_street'],
'billing_city' => $data['billing_city'],
'billing_postal_code' => $data['billing_postal_code'],
'billing_country' => $data['billing_country'],
'vat_number' => $data['vat_number'],
]);
});
// Optional: run logic before the Mollie payment is created.
// Return null to proceed, or a string to block checkout with that error message.
MollieBilling::beforeCheckoutUsing(function (Billable $billable): ?string {
// e.g. create a User and attach to the billable
return null;
});
// Optional: run cleanup after checkout succeeds or fails.
MollieBilling::afterCheckoutUsing(function (Billable $billable, bool $success): void {
if (! $success) {
// e.g. delete the orphaned user
}
});
// Optional: cascade-delete logic for billables abandoned mid-checkout. The
// CleanupOrphanedBillablesJob runs every 15 minutes and identifies billables
// that never reached an active subscription. When a closure is registered it
// receives the billable and is responsible for cascading cleanup (e.g.
// deleting tenants, users with no other organizations, etc.). Without a
// closure the package falls back to `$billable->delete()`.
//
// The closure may return `false` to veto cleanup for billables that
// legitimately exist without a subscription (admins, employees, internal
// accounts). The job then suppresses ALL side-effects — no CheckoutAbandoned
// event, no mandate revocation, no log entry. Returning `true` or `void`
// behaves like before.
MollieBilling::cleanupOrphanedBillableUsing(function (Billable $billable): bool {
if ($billable instanceof User && ($billable->isAdmin() || $billable->isEmployee())) {
return false;
}
DB::transaction(function () use ($billable): void {
foreach ($billable->users()->get() as $user) {
if ($user->organizations()->where('id', '!=', $billable->id)->doesntExist()) {
$user->forceDelete();
}
}
$billable->forceDelete();
});
return true;
});
<a href="{{ MollieBilling::checkoutUrl('/pricing') }}">Subscribe now</a>
The optional $backUrl parameter controls where the "Back" link in the checkout header leads. When omitted, the package falls back to config('mollie-billing.checkout_back_url') (default /).
To pre-select a plan and/or billing interval, pass them as additional parameters:
<a href="{{ MollieBilling::checkoutUrl('/pricing', plan: 'pro', interval: 'yearly') }}">
Get Pro yearly
</a>
The plan step will still be shown so the customer can change their mind, but the given plan will be pre-selected. Invalid plan codes or intervals are silently ignored.
By default the checkout shows all 27 EU member states. Customize via config:
// config/mollie-billing.php
'checkout_countries' => [
'regions' => ['EU'], // built-in: 'EU' (27 member states)
'include' => ['CH', 'GB'], // additional ISO codes
'exclude' => ['MT'], // remove from the list
],
// Countries defined here are auto-included in the checkout selector:
'additional_countries' => [
'CH' => ['vat_rate' => 8.1, 'name' => 'Switzerland'],
],
Country names are translated via the package's billing::countries lang files (English and German included). Publish and extend them for additional locales:
php artisan vendor:publish --tag=billing-lang
If your app needs additional steps before the billing-address form (e.g. "Create your account"), register them via the facade. Custom steps are inserted before the package's built-in steps; numbering, timeline and navigation adjust automatically.
use Livewire\Component;
use GraystackIT\MollieBilling\Facades\MollieBilling;
// AppServiceProvider::boot()
MollieBilling::checkoutStepsUsing(fn () => [
[
'key' => 'account',
'label' => 'Account',
'headline' => 'Create your account',
'description' => 'Set up your login credentials before we continue.',
'view' => 'checkout.steps.account', // your app's Blade view
'validate' => function (Component $component) {
$component->validate([
'customData.name' => ['required', 'string', 'max:255'],
'customData.email' => ['required', 'email', 'unique:users,email'],
]);
},
],
]);
Each step definition requires:
| Key | Type | Description |
|---|---|---|
key |
string |
Unique identifier for the step. |
label |
string |
Short label shown in the timeline. |
headline |
string |
Heading displayed above the step content. |
description |
string |
Subheading text below the headline. |
view |
string |
Blade view name to @include for this step's form fields. |
validate |
Closure |
(optional) Receives the Livewire Component instance. Throw a ValidationException (or call $component->validate(...)) to block navigation. |
Binding form data — The checkout component exposes a public array $customData = [] property. Use wire:model with dot notation in your step view:
{{-- resources/views/checkout/steps/account.blade.php --}}
<div class="flex flex-col gap-5">
<flux:input wire:model.live="customData.name" label="Full name" required />
<flux:input wire:model.live="customData.email" label="Email" type="email" required />
<flux:input wire:model="customData.password" label="Password" type="password" required />
</div>
The customData array is passed to your createBillableUsing callback as $data['custom'], so you can access it when creating the billable:
MollieBilling::createBillableUsing(function (array $data) {
$user = User::create([
'name' => $data['custom']['name'],
'email' => $data['custom']['email'],
'password' => Hash::make($data['custom']['password']),
]);
$org = Organization::create([
'name' => $data['name'],
'billing_street' => $data['billing_street'],
'billing_city' => $data['billing_city'],
'billing_postal_code' => $data['billing_postal_code'],
'billing_country' => $data['billing_country'],
'vat_number' => $data['vat_number'],
]);
$user->organizations()->attach($org);
return $org;
});
You can register multiple custom steps — they appear in the order returned by the callback.
All Livewire views (checkout, portal, admin) can be published and customized:
php artisan vendor:publish --tag=mollie-billing-views
Views are published to resources/views/vendor/mollie-billing/. SFC files use the ⚡ prefix convention (e.g. ⚡checkout.blade.php).
The package's Blade views use Tailwind utility classes (including responsive breakpoints like sm:, lg:). Your host app's Tailwind build must scan the package views, otherwise these classes will be purged.
Tailwind v4 — add a @source directive in your resources/css/app.css:
@source "../../vendor/graystackit/laravel-mollie-billing/resources/views/**/*.blade.php";
Tailwind v3 — add the path to the content array in tailwind.config.js:
content: [
// ...
'./vendor/graystackit/laravel-mollie-billing/resources/views/**/*.blade.php',
],
Without this, responsive grid layouts and other utility classes in the portal, checkout and admin panel may not render correctly.
Highlights of config/mollie-billing.php:
| Key | Purpose |
|---|---|
currency |
Default currency for prices and invoices (e.g. EUR). |
logo_url |
Logo displayed in checkout and portal headers. |
primary_color |
Accent color for checkout UI (hex, e.g. #6366f1). |
dashboard_url |
URL the portal logo links to (e.g. your app's main dashboard). Supports route: prefix. |
checkout_back_url |
Where the checkout "Back" link leads (default /). |
checkout_countries |
Countries shown in checkout (regions, include, exclude). |
allow_overage_default |
Default policy when a plan does not declare its own overage rule. |
additional_countries |
ISO-3166 codes + VAT rates for non-EU jurisdictions. |
vat_rate_overrides |
Map of country code to override VAT percentage. |
company_name |
Display name used in headers, notifications and signatures. |
billable_model |
Fully-qualified class name of your billable model. |
billable_key_type |
uuid, ulid, or int — determines morph column shape. |
user_key_type |
uuid, ulid, or int — primary key type of your auth user model (used e.g. for billing_country_mismatches.resolved_by_user_id). |
billing_timezone |
IANA timezone for the customer portal display (BILLING_TIMEZONE, default UTC). Persistence and computation always remain UTC; the admin panel renders UTC. See Timezones. |
By default the portal logo links to the billing dashboard itself. Set dashboard_url to link it to your app's main dashboard instead — a "Back to dashboard" link will also appear at the bottom of the sidebar:
# Plain URL:
BILLING_DASHBOARD_URL=/dashboard
# Or a named route (prefix with "route:"):
BILLING_DASHBOARD_URL=route:dashboard
Define your catalog in config/mollie-billing-plans.php. Free plans run as SubscriptionSource::Local (no Mollie subscription), paid plans are SubscriptionSource::Mollie.
<?php
return [
'plans' => [
'pro' => [
'name' => 'Pro',
'tier' => 2,
'included_seats' => 3,
'feature_keys' => ['dashboard', 'advanced-reports'],
'allowed_addons' => ['softdrinks'],
'intervals' => [
'monthly' => [
'base_price_net' => 2900,
'seat_price_net' => 990,
'trial_days' => 14, // optional, per-interval trial length
// Included quota per billing period (here: per month)
'included_usages' => ['tokens' => 100, 'sms' => 50],
// Cents per unit over quota; omit a key for "no overage"
'usage_overage_prices' => ['tokens' => 10, 'sms' => 15],
],
'yearly' => [
'base_price_net' => 29000,
'seat_price_net' => 9900,
'trial_days' => 14,
// Included quota per billing period (here: per year)
'included_usages' => ['tokens' => 1500, 'sms' => 600],
'usage_overage_prices' => ['tokens' => 10, 'sms' => 15],
],
],
],
],
'addons' => [
'softdrinks' => [
'name' => 'Softdrinks',
'feature_keys' => ['softdrinks'],
'intervals' => [
'monthly' => ['price_net' => 490],
'yearly' => ['price_net' => 4900],
],
],
],
];
A minimal billable model needs the trait plus one required method — getUsedBillingSeats(). This method is intentionally not provided by the trait because only your app knows how to count active seats (team members, users, etc.):
class Organization extends Model implements Billable
{
use HasBilling;
public function getUsedBillingSeats(): int
{
return $this->users()->count();
}
}
The seat count is used during plan-change previews to calculate whether extra seats need to be purchased on the new plan.
HasBilling provides among others:
recordBillingUsage($type, $quantity) and creditBillingUsage(...)hasPlanFeature('reports.export')cancelBillingSubscription(), changeBillingPlan(...), enableBillingAddon(...)billingPortalUrl(), billingPlanChangeUrl()latestBillingInvoice() and billingInvoices() morph relationgetWallet($type) / hasWallet($type) / createWallet($data) — overridden bavix wrappers that resolve usage-type slugs case-insensitively (so tokens, Tokens, and TOKENS all hit the same wallet, and createWallet will not insert a duplicate row when a casing variant already exists). Catalog lookups (includedUsage, usageOveragePrice) follow the same case-insensitive rule. See Usage Billing — Casing of usage-type identifiers.The checkout collects a company name and writes it through the getBillingName() / setBillingName() pair on the billable. By default both methods read and write the model's name column, which is fine when the billable is a dedicated Organization / Tenant model.
When your billable is the User model, however, name typically already stores the user's personal name. To keep the personal name intact while still letting the customer enter a company name for invoices, override the two methods (or billingNameAttribute()) on the billable model:
class User extends Authenticatable implements Billable
{
use HasBilling;
// Option A — point both accessor and mutator at a different column:
protected function billingNameAttribute(): string
{
return 'practice_name';
}
// Option B — full control, e.g. compute or fall back:
public function getBillingName(): string
{
return $this->practice_name ?? '';
}
public function setBillingName(string $name): void
{
$this->practice_name = $name;
}
}
The name key passed to your createBillableUsing callback always carries the company name from the checkout form — your callback decides which attribute to persist it into.
When the billable table also stores rows that are not customers — typically a single users table that mixes admins/staff with paying users — override applyBillingScope() on the model. The HasBilling trait registers BillingScope as a global scope and delegates to this method, so the same filter applies to admin listings, KPI queries, and every lifecycle job that iterates billables.
use GraystackIT\MollieBilling\Concerns\HasBilling;
use GraystackIT\MollieBilling\Contracts\Billable;
use Illuminate\Database\Eloquent\Builder;
class User extends Authenticatable implements Billable
{
use HasBilling;
public function applyBillingScope(Builder $query): void
{
$query->where('is_customer', true);
}
}
Bypass the scope on the rare lookups that must reach every row regardless of the app-defined restriction (Mollie webhook resolution, retry jobs, admin impersonation):
User::withoutGlobalScope(\GraystackIT\MollieBilling\Scopes\BillingScope::class)->find($id);
The default implementation is a no-op, so models where the table only contains billables need not override it.
| Type | Behavior | Quick example |
|---|---|---|
SinglePayment |
Discounts only a single invoice (Subscription Checkout or One-Time-Order). 100 % is supported — Subscription Checkout uses a Mandate-Only flow so Mollie keeps a mandate for period 2; One-Time-Order skips Mollie entirely and writes a local 0-EUR audit invoice. | MollieBilling::coupons()->singlePaymentCoupon('LAUNCH', 50, 'percent'); |
Recurring |
Discounts each invoice for N periods (Subscription Checkout or any plan-change-style flow). 100 % is supported via a deferred Mollie startDate. Not accepted on One-Time-Orders (no follow-up charges to attach to). |
MollieBilling::coupons()->recurringCoupon('LOYAL', 10, 'percent', periods: 6); |
Credits |
Adds wallet credit balance. | MollieBilling::coupons()->creditsCoupon('PROMO5', cents: 500); |
TrialExtension |
Extends the active trial. | MollieBilling::coupons()->trialExtensionCoupon('EXTEND14', days: 14); |
AccessGrant |
Grants free access without payment. | See below. |
Access Grants come in two flavors — full-plan grants and addon-only grants:
use GraystackIT\MollieBilling\Facades\MollieBilling;
// Full plan access for 90 days, no payment method required:
MollieBilling::coupons()->accessGrantCoupon(
code: 'BETA90',
planCode: 'pro',
interval: 'monthly',
days: 90,
);
// Addon-only grant — the customer keeps their existing plan:
MollieBilling::coupons()->addonGrantCoupon(
code: 'PRIORITY30',
addonCode: 'priority_support',
days: 30,
);
The update orchestrator handles plan changes, addon toggles and seat sync atomically:
use GraystackIT\MollieBilling\Facades\MollieBilling;
MollieBilling::subscriptions()->update($organization, [
'plan_code' => 'pro',
'interval' => 'yearly',
'addons' => ['priority_support' => true],
'seats' => 12,
'apply' => 'immediate', // or 'end_of_period'
]);
The package distinguishes two subscription sources via the subscription_source column:
mollie — a real Mollie subscription with mandate, recurring charges and invoices.local — a free / coupon-granted subscription with no Mollie mandate. The wallet receives the included usages on activation (and at scheduled renewals via PrepareUsageOverageJob), but no money flows.| Trigger | Service | Notes |
|---|---|---|
| Free plan checkout | StartSubscriptionCheckout → ActivateLocalSubscription |
Mollie returns no checkout_url for a 0 € first payment; the app activates the plan locally. |
AccessGrant coupon |
CouponService::applyAccessGrant → ActivateLocalSubscription |
Coupon-granted plans (timed or unlimited) live as Local. |
| Mollie → Free downgrade | UpdateSubscription |
Cancels the Mollie subscription, sets subscription_source = local, status remains active, wallets are rebalanced (purchased credits preserved). |
A Local subscription has no Mollie mandate, so no money can flow from the customer. Anything that would result in a charge is blocked.
| Operation | Allowed? |
|---|---|
| Free addons (price 0) | yes |
| Paid addons | no — LocalSubscriptionDoesNotSupportPaidExtrasException |
Extra seats on a plan with seat_price_net > 0 |
no — same exception |
| Switch to another free plan | yes |
Switch directly to a paid plan via UpdateSubscription |
no — LocalSubscriptionUpgradeRequiresMolliePathException. Use UpgradeLocalToMollie instead (the bundled plan-change UI does this automatically). |
Track metered usage (recordBillingUsage) |
yes — included quota is credited and reset at period boundaries |
| Charge the customer for usage overages | no — PrepareUsageOverageJob only charges Mollie + mandate billables. Negative balances on Local subs are silently reset at the next period. See docs/usage-billing.md. |
| Purchase one-time products | opt-in via config('mollie-billing.local_subscription.allow_one_time_orders'). Default is false — purchase attempts throw LocalSubscriptionCannotPurchaseProductsException and the products page hides the buy buttons. Set to true if your business model treats the free plan as a default tier monetised through token packs etc. |
| Cancel | yes — status switches to cancelled, wallets are kept until subscription_ends_at. |
If you need to bill free-tier users for usage overages or sell them seat upgrades, do not ship the plan at price 0. Set the lowest non-zero amount that still makes commercial sense — that triggers the regular Mollie checkout, captures a mandate, and makes the user a paid (Mollie-source) subscriber.
use GraystackIT\MollieBilling\Services\Billing\UpgradeLocalToMollie;
['checkout_url' => $url, 'payment_id' => $id] = app(UpgradeLocalToMollie::class)->handle($organization, [
'plan_code' => 'pro',
'interval' => 'monthly',
'addon_codes' => [],
'extra_seats' => 0,
'amount_gross' => $previewedGross, // pre-computed by PreviewService
]);
return redirect()->away($url);
The webhook on the resulting first payment carries metadata.upgrade_from_local = true and routes through MollieWebhookController::handleLocalToMollieUpgrade(), which reuses the existing wallet (purchased balance preserved) instead of seeding a fresh one.
The bundled plan-change UI (resources/views/livewire/billing/⚡plan-change.blade.php) detects Local → paid plan automatically and shows a confirmation step before the Mollie redirect — no second checkout wizard.
A user-initiated downgrade follows whatever config('mollie-billing.plan_change_mode') is set to (Immediate, EndOfPeriod, UserChoice). For EndOfPeriod, ScheduleSubscriptionChange queues the change and PrepareUsageOverageJob applies it at the period boundary — the same Mollie cancel + Source=Local flip happens then.
purchased_balance (one-time orders, coupon credits) is preserved across every plan change, including downgrades to free.
Preview the financial impact of an update before applying it:
$preview = MollieBilling::preview()->previewUpdate($organization, [
'plan_code' => 'pro',
'interval' => 'yearly',
]);
// $preview->prorataCredit, $preview->newChargeGross, $preview->vatAmount, ...
Three convenience methods cover the common cases:
use GraystackIT\MollieBilling\Facades\MollieBilling;
// Refund a full invoice and issue a credit note:
MollieBilling::refunds()->refundFully($invoice, RefundReasonCode::BillingError);
// Partial refund of a specific net amount (in cents):
MollieBilling::refunds()->refundPartially($invoice, 500, RefundReasonCode::Goodwill, 'customer request');
// Refund specific overage units (auto-calculates amount from unit price, credits wallet):
MollieBilling::refunds()->refundOverageUnits($invoice, 'tokens', 1_000, RefundReasonCode::Goodwill);
// Wallet-only credit without touching Mollie — use WalletUsageService directly:
app(WalletUsageService::class)->credit($organization, 'tokens', 500, 'goodwill bonus');
The admin panel lives at /billing/admin. Authorize access by implementing AuthorizesBillingAdmin directly on your user model. The billing.admin middleware checks auth()->user() instanceof AuthorizesBillingAdmin && canAccessBillingAdmin(); users without the interface receive a 403.
<?php
namespace App\Models;
use GraystackIT\MollieBilling\Contracts\AuthorizesBillingAdmin;
use Illuminate\Foundation\Auth\User as Authenticatable;
class User extends Authenticatable implements AuthorizesBillingAdmin
{
public function canAccessBillingAdmin(): bool
{
return $this->is_admin === true;
}
}
Generate signed promotion URLs that auto-apply a coupon when the customer follows them:
https://your-app.test/promotion/{token}
Tokens are generated via MollieBilling::coupons()->promotionToken($coupon).
Every state change dispatches a Laravel event so apps can react via listeners. Notable events include:
CheckoutStarted, CheckoutAbandonedSubscriptionCreated, SubscriptionCancelled, SubscriptionExpired, SubscriptionResumedPlanChanged, SubscriptionUpdated, SubscriptionChangeScheduledTrialStarted, TrialConverted, TrialExpired, TrialExtendedMandateUpdatedPaymentSucceeded, PaymentFailed, PaymentAmountMismatch, DuplicatePaymentReceivedInvoiceCreated, InvoiceRefunded, CreditNoteIssued, InvoicePdfRegeneratedOverageCharged, OverageChargeFailedCouponRedeemed, GrantRevokedCountryMismatchFlagged, CountryMismatchResolvedWalletCredited, UsageLimitReachedSubscribe in your EventServiceProvider exactly like any other Laravel event.
The package ships with helpers for both unit and feature tests:
use GraystackIT\MollieBilling\Facades\MollieBilling;
use GraystackIT\MollieBilling\Testing\TestBillable;
use GraystackIT\MollieBilling\Testing\BillableStateHelper;
MollieBilling::fake();
$billable = TestBillable::factory()->create();
BillableStateHelper::onPaidPlan($billable, 'pro', 'monthly');
$billable->recordBillingUsage('api_calls', 1_500);
MollieBilling::assertSubscriptionStarted($billable);
# Re-queue overage charges for everyone whose period ended in the last hour
php artisan billing:prepare-overage
# Export the OSS report for a given calendar year (writes to the configured
# OSS disk — S3-compatible — and persists a downloadable row that the admin
# panel surfaces alongside queued/admin-triggered exports)
php artisan billing:oss-export 2026
# Validate the syntax and semantic integrity of mollie-billing.php and mollie-billing-plans.php
php artisan billing:check-config
# Delete billables that were created during checkout but never reached an active
# subscription (abandoned tabs, expired Mollie sessions, captured-but-unused
# mandates). Runs every 15 minutes via the scheduler in normal operation; this
# command is for manual / on-demand cleanup.
php artisan billing:cleanup-orphans
# Reproduce subscription-lifecycle transitions on a staging system. Interactive
# walkthrough without arguments, single flow with one. Non-production only.
php artisan billing:simulate
php artisan billing:simulate trial-expired --billable=42
# Replay a Mollie payment through the webhook handler. Non-production only.
php artisan billing:webhook-replay tr_xxxxxxxxx
See docs/testing-flows.md for the full list of simulated flows, options, and what is/isn't covered.
billing:check-config reports two classes of issues:
billable_model, plan feature_keys pointing at undefined features, invoices.disk not declared in config/filesystems.php, invalid serial-number format, unknown plan_change_mode, …). Exits with status 1.included_usages quota without a matching usage_overage_prices entry, features defined but never referenced, ambiguous tier ranking, …). Exit status stays 0.Run it after editing either config file or as part of CI to catch typos before deployment.
Detailed technical documentation is available in the docs/ directory:
mollie-billing.php and mollie-billing-plans.php referenceuseNotification()billing:simulate and billing:webhook-replay for reproducing every lifecycle transition on stagingThis package wraps mollie/laravel-mollie ^4 and adds a VAT/OSS layer (mpociot/vat-calculator plus VIES), a wallet layer for metered billing (bavix/laravel-wallet), a coupon engine, a built-in first-checkout wizard, an admin panel and a Livewire 4 customer portal. Subscription lifecycle is split into single-purpose service classes per action (Start, Create, Activate, Change, Cancel, Resubscribe, EnableAddon, DisableAddon, SyncSeats) — the HasBilling trait delegates to them via the container, so apps customize behavior by rebinding services rather than subclassing models. Extension points are provided via facade callbacks (createBillableUsing, beforeCheckoutUsing, afterCheckoutUsing, resolveBillableUsing, etc.) and events.
Webhook handling is similarly split: MollieWebhookController is a thin façade that reserves the payment, fetches it from Mollie, and routes by payment type to a dedicated handler in src/Services/Webhook/ (FirstPaymentHandler, MandateOnlyPaymentHandler, SubscriptionPaymentHandler, ProrataChargeHandler, SingleChargeHandler, CountryCorrectionHandler, LocalToMollieUpgradeHandler, OneTimeOrderHandler, RefundHandler). Each handler is auto-resolved from the container — apps can rebind any of them to customize per-payment-type behavior without touching the controller.
Free or zero-price plans run as SubscriptionSource::Local without a Mollie subscription; paid plans are SubscriptionSource::Mollie. Paid plans with a configured trial_days go through a Mandate-Only checkout (0 EUR, captures the payment method) and create the Mollie subscription with startDate = now + trial_days — no charge during the trial, status = trial, wallet hydrated aliquot to the trial length. See Subscription Lifecycle.
The MIT License (MIT). See LICENSE for details.
How can I help you explore Laravel packages today?