graystackit/laravel-mollie-billing
Batteries-included Mollie billing for Laravel with VAT/OSS compliance, wallet-based metered billing, coupons, scheduled plan changes, trials, an admin panel and a Livewire 4 customer portal, all keyed off a Billable contract.
Minimum viable wiring — do these in order; several are order-dependent:
composer require graystackit/laravel-mollie-billingcomposer require livewire/flux-pro — not pulled in transitively; it's a commercial license you must own. The portal, checkout, and admin panel all hard-depend on it.php artisan vendor:publish --tag=mollie-billing-config and --tag=mollie-billing-migrations.config/mollie-billing.php and set billable_model, billable_key_type (uuid|ulid|int), and user_key_type. These drive the column type of every polymorphic FK (including bavix/laravel-wallet's wallets.holder_id, transactions.payable_id, transfers.*_id) — get them wrong and you'll be hand-ALTERing tables later.php artisan migrateHasBilling + implement Billable on your tenant model (e.g. Organization), not User. Provide getUsedBillingSeats() so seat-based metering has a number to read..env: BILLING_MOLLIE_KEY, BILLING_BILLABLE_MODEL, BILLING_BILLABLE_KEY_TYPE, BILLING_USER_KEY_TYPE, BILLING_CURRENCY.resolveBillableUsing + authUsing in AppServiceProvider::boot().@source line for vendor/graystackit/laravel-mollie-billing/resources/views/** (v4) or the content glob (v3). Without it the portal renders unstyled.php artisan billing:check-config — it checks the webhook route exists, feature_keys/allowed_addons/product group references, and syntax in both config files.Where to look first:
config/mollie-billing.php + config/mollie-billing-plans.php — this pair is the product. Plans, features, addons, quotas, overage prices, currency, country list all live here.docs/vat-handling.md — the country-mismatch reconciliation logic (three-way: declared vs payment vs IP) is the most surprising part of the package and fires on every recurring payment.docs/audit.md — audit rows store a translation key plus raw values, not rendered text.First use case to prove out: new Organization → hit the checkout route → complete first Mollie payment → confirm the webhook creates the local subscription → hit the portal /billing/plan and toggle a plan. If that round-trip works, VAT, mandates, and the billable model are all wired correctly.
Route mounting is four distinct groups, with deliberate middleware boundaries. This is the core integration shape, not boilerplate:
// routes/web.php — portal, tenant-scoped
Route::middleware(['web', 'auth', 'tenant'])->group(fn () => MollieBilling::routes());
// routes/web.php — checkout, NOT tenant-scoped (tenant doesn't exist yet)
Route::middleware(['web', 'auth'])->group(fn () => MollieBilling::checkoutRoutes());
// routes/web.php — admin (auth only; auto-loaded unless you disable it)
Route::middleware(['web', 'auth'])->group(fn () => MollieBilling::adminRoutes());
// routes/api.php — webhook, NO `web` group (no session, no CSRF, no tenant)
MollieBilling::webhookRoutes();
Multi-tenant URL prefixes — mount MollieBilling::routes() inside your tenant prefix group but do not wrap it in your own ->name('tenant.'); package views call route('billing.*') by exact name. PropagateRouteDefaults (shipped) copies the active route params into URL::defaults, so links inside the portal carry the {organization:slug} automatically.
For non-request contexts (queued notifications, jobs, Mollie redirect URLs, emails), PropagateRouteDefaults can't help — register a global resolver:
MollieBilling::urlParametersUsing(
fn (?Billable $billable) => $billable ? ['organization' => $billable->slug] : []
);
A model-level urlRouteParameters() override takes precedence over this if you need per-model logic.
Authorization is a callback, and it's load-bearing. Two closures in AppServiceProvider::boot():
MollieBilling::resolveBillableUsing(fn () => auth()->user()?->currentOrganization);
MollieBilling::authUsing(
fn (Request $r, Billable $b): bool => $r->user()?->belongsToBillable($b) ?? false
);
Every cross-tenant defence in the package delegates to authUsing — portal middleware, invoice downloads, the checkout prefill gate. Implement it as an actual membership check, not auth()->check().
Feature gating — two flavours, pick per surface: @planFeature('api_access') in Blade, billing.feature:api_access middleware on routes. Feature keys must exist in mollie-billing-plans.php; billing:check-config flags unknown ones.
Metered billing flow — record usage against a usage-type (lookups are case-insensitive), let the wallet consume included quota, and the package charges overage at the configured price. Direct overage charging has retry and a past_due state; treat past_due as a first-class state in your UI, not an edge case.
Coupons — five types: SinglePayment, Recurring, Credits, TrialExtension, AccessGrant. AccessGrant is how you comp a full plan or addon-only access; don't build a parallel "free account" path. Promotion links are signed /promotion/{token} URLs via MollieBilling::promotionRoutes() (mount without tenant scope) for auto-apply.
Trial conversion — trial lives locally, converted to a Mollie subscription at end of trial. Plan changes are scheduled (prorata upgrades, end-of-period downgrades) — build admin/portal UIs around the scheduled state, not just the current plan.
The webhook route in the web group is the #1 footgun. Every payment the package creates embeds this URL; if you mount it inside web, the very first checkout throws RouteNotFoundException [billing.webhook] before it ever reaches Mollie. It must be session-less, CSRF-less, tenant-less, and the route must exist — billing:check-config verifies this, so run it after mounting routes.
billable_key_type / user_key_type are immutable after first migrate. The package rewrites bavix/laravel-wallet's default bigint FK columns to uuid/ulid based on these values. Changing them later means manually altering wallets.holder_id, transactions.payable_id, transfers.{from,to}_id, billing_country_mismatches.resolved_by_user_id, etc. Set them once, correctly.
The admin route double-registration trap. Admin routes auto-load from the service provider. If you also call MollieBilling::adminRoutes() under custom middleware without setting BILLING_AUTO_LOAD_ADMIN_ROUTES=false, the auto-loaded routes match the URI first and your middleware never runs. Either use the auto-load or disable it — not both.
authUsing is your cross-tenant gate, and the checkout query-param fallback depends on it. The checkout route sits outside tenant scope, so resolveBillableUsing returns null there. The package then falls back to reading the billable from query params (?organization=acme-corp, matching getRouteKeyName(); reserved keys back, plan, interval, redirect, token are skipped). That fallback adopts the resolved billable's billing data only if authUsing authorizes it. A permissive authUsing (e.g. fn() => auth()->check()) turns ?organization=<someone-else> into a disclosure of that tenant's address and VAT number.
flux-pro is a hard blocker, not a soft dependency. No Flux Pro, no checkout, no portal, no admin — with a commercial license required per app. Budget for it; it's not a composer require you can skip.
No compiled CSS/JS ships with the package. The shipped layouts call @vite(['resources/css/app.css', 'resources/js/app.js']), so your app must expose those entry points and your Tailwind build must scan the vendor views. Tailwind v4: @source "vendor/graystackit/laravel-mollie-billing/resources/views"; (or equivalent). Omit this and the portal silently renders unstyled. Publishing views (--tag=mollie-billing-views) helps only if your Tailwind already scans resources/views/** — the @source line is still needed for any unpublished views.
Country-mismatch reconciliation silently cancels. On each recurring payment, three country signals (user-declared, payment-derived, IP-derived) are compared. If the declared country matches neither signal, the subscription is set to cancel-at-period-end, the billable is emailed, and a dashboard modal offers self-correction (refund + reissue at the new rate). B2B billables with a VIES-validated VAT number bypass the check entirely (reverse-charge makes bank country fiscally irrelevant). Watch your logs for this; a cancellation you didn't initiate is probably this, not a Mollie problem. Admins can override to any ISO-2 code, but only against a written justification that lands in the audit trail.
Audit rows hold translation keys, not rendered strings. The timeline reads correctly in any locale because the package stores key + raw values. If you render the audit trail yourself, translate at display time — don't cache rendered rows.
Usage-type lookups are case-insensitive. Fine for ergonomics, but means api_calls, API_CALLS, and Api_Calls are the same bucket. Standardize constants on your side; don't rely on case to distinguish usage types.
billing:check-config is your pre-deploy gate. It checks both config files for syntax errors, broken references (unknown feature_keys, allowed_addons, product group), and the webhook route. Wire it into CI — config drift after adding a plan is easy to miss otherwise.
How can I help you explore Laravel packages today?