LarsaSub is launching soon. Purchases and account registration open shortly.

Documentation for larsasub v0.38.2. On an older version? Your release notes ship with the package (CHANGELOG.md).

Configuration

Everything LarsaSub reads lives in config/larsasub.php, which larsasub:install publishes (or php artisan vendor:publish --tag=larsasub-config). This page walks the file block by block, in the order the file declares them: each key with its default, its environment variable and what it does.

Every setting has a working default. The blocks you are most likely to touch first are Billable model, Invoicing and Standalone dashboards. The environment variables on their own are listed in Environment variables.

Validation and upgrades

LarsaSub checks its own configuration on every boot (Support\ConfigValidator) and reports what it finds at two severities. You rarely need to think about this until a value is wrong — but knowing the two severities tells you where to look when it is.

Severity What it catches What happens
Fatal Configuration that is provably broken: a class that does not exist or implements the wrong contract, a value an enum-shaped key does not know Boot throws a LarsaSubException naming every fatal. Only the recovery commands still run — cache:clear, config:cache, config:clear, help, larsasub:doctor, larsasub:install, larsasub:license, list, optimize:clear, package:discover, vendor:publish — so a cached broken config can be repaired
Warning Configuration that works, silently, in a way you almost certainly do not want: nobody is an admin, company mail goes nowhere, a schedule that collects late Logged to the LarsaSub log channel on boot, at most once an hour for an unchanged set of warnings

php artisan larsasub:doctor prints the same fatals and warnings on demand, plus the checks boot cannot make: whether configured route names exist, whether the schema has every column, whether a published view predates a key you set, and findings that need the database. It exits non-zero on an error; --strict also fails on warnings, and --live adds one read-only call to the provider. The sections below say which keys these checks cover.

A published config does not pick up new nested keys. Laravel merges your published config/larsasub.php with the package's own at the top level only. A block you published before a key was added keeps your copy, and the new key reads as null — which for some keys switches a feature off (a missing retention.notification_deliveries_days disables pruning). larsasub:doctor lists every key the shipped file has and yours lacks. Re-publish with php artisan vendor:publish --tag=larsasub-config --force, or copy the keys in by hand.

Database connection

Every LarsaSub table lives on one connection. Change it when billing state should sit apart from your application's own data.

'database' => [
    'connection' => env('LARSASUB_DB_CONNECTION', null),
],
Key Default Env Description
connection null LARSASUB_DB_CONNECTION The connection every LarsaSub model and migration uses. null follows the framework's default connection

null is right for a single-database app. Multi-tenant apps typically point it at a landlord connection, so billing state survives independently of the per-tenant databases:

'connection' => env('LARSASUB_DB_CONNECTION', 'landlord'),

The package also registers a larsasub channel on peters-development/laravel-model-logger with this connection, so model-log rows land beside the records they describe — unless you declare that channel yourself in the model logger's config.

Testing. If your test suite points this at a differently named connection that shares the default's PDO, RefreshDatabase's wrapping transaction becomes invisible to the package and every money-path test dies with "cannot start a transaction within a transaction". See Testing.

Billable model

This block decides which of your models holds subscriptions and gets charged, and how the package finds it from the signed-in user.

'billable' => [
    'model' => \App\Models\User::class,
    'resolution_mode' => env('LARSASUB_BILLABLE_RESOLUTION_MODE', 'auto'),
    'owner_resolver' => null,
    'relation' => null,
    'platform_model' => null,
],
Key Default Env Description
model App\Models\User — The class that holds subscriptions and gets charged
resolution_mode 'auto' LARSASUB_BILLABLE_RESOLUTION_MODE auto or explicit — see below
owner_resolver null — Explicit mode only: an invokable class returning the current owner from request context
relation null — null = the user is the billable; 'team' = $user->team() is
platform_model null — Dual billing: the model the platform bills with its own Mollie key

model must implement Contracts\BillableModel (a boot failure otherwise) and use Traits\IsBillableModel. With the Mollie provider it also needs IsMollieBillableModel; larsasub:doctor reports the methods that are missing. See Billable models.

Direct billing (relation => null) makes the authenticated model itself the billable. Indirect billing (relation => 'team') names a relation on the user that returns the billable, so several users share one subscription.

Explicit mode is for apps where one user owns many billables — a multi-tenant SaaS where the "current" tenant comes from the subdomain, not from a user attribute. In that mode BillableResolver::resolve() throws unless owner_resolver is configured, and you pass the owner explicitly everywhere else. Without a resolver, ownership policies and the bundled dashboards fail closed — they deny, never a 500. See Billable models.

owner_resolver is called as __invoke(?Authenticatable $user): ?Model and must return null (no current owner, e.g. on landlord routes) or a model implementing BillableModel. It runs in the request, so reading request state is what it is for. It is ignored outside explicit mode.

platform_model sets up dual billing alongside Connect: the platform bills this model through its own Mollie key, while the primary model is billed through the tenant's Connect account. Both classes must implement BillableModel.

Boot fails on a model or platform_model that is not an existing class implementing BillableModel, a resolution_mode other than auto or explicit, and an owner_resolver that is not an existing invokable class.

Payment provider

Mollie is the only provider the package ships. These two keys exist so a host can replace or extend it without patching the package.

'provider' => env('LARSASUB_PROVIDER', 'mollie'),

'providers' => [
    'mollie' => \PetersDevelopment\LarsaSub\PaymentProviders\Mollie\MollieService::class,
],
Key Default Env Description
provider 'mollie' LARSASUB_PROVIDER The active provider: a key of providers
providers ['mollie' => MollieService::class] — Provider name → service class

provider selects the active provider; providers is the registry it is looked up in. A host can register its own implementation here (it must extend PaymentProviders\Contracts\PaymentProviderService) or point an existing name at a subclass. Services resolve through the container, so a container binding can override behaviour without touching this map.

The package registers the active provider's own routes — its webhooks and payment initiation — through this registry, so a custom provider gets its webhook routes, not Mollie's. Boot fails when provider is not a key of providers, or a class in it does not exist.

Currency

LarsaSub bills in euros. default is the currency the package creates money in itself; allowed is an allow-list for the two places where a caller names one.

'currency' => [
    'default' => env('LARSASUB_CURRENCY_DEFAULT', 'EUR'),
    'allowed' => array_filter(array_map('trim', explode(',', env('LARSASUB_CURRENCY_ALLOWED', 'EUR')))),
],
Key Default Env Description
default 'EUR' LARSASUB_CURRENCY_DEFAULT The currency new plans, credit and mandate verifications are created in
allowed ['EUR'] LARSASUB_CURRENCY_ALLOWED Comma-separated in the env value. The currencies a caller may pass

allowed is enforced, not documentation. The two entry points where a caller supplies a currency reject anything outside it, compared case-insensitively: SinglePaymentService::createSinglePayment() with a SinglePaymentException, and RecurringChargeService::createStandaloneCharge() with a RecurringChargeException. Boot fails when default is not in allowed.

EUR is the only supported default. Plans, subscriptions, the €0,01 mandate verification and credit all use that one currency, and the package converts nothing. Another default is not supported yet: SEPA Direct Debit, iDEAL and Bancontact take euros only, the VAT rules zero-rate every customer outside the EU (a seller in the UK or Switzerland would charge its own domestic customers no VAT), and the display formats money the euro way.

A customer outside the euro area pays in euros, by card or PayPal; their card issuer or PayPal converts it to their own currency.

A second code in allowed lets those two entry points collect in it, at the amount you pass: Mollie collects in that currency, and only a method that accepts it (a card, PayPal) can complete the payment. Its invoice and credit note state that currency. Such a charge is never paid from credit — the credit account is in default, and credit is applied only within one currency (Credit) — and neither is a chargeback follow-up of it. A plan's price_currency is not checked against allowed: keep it at default.

Content locales

This block turns on per-language plan content and fixes the one language the package writes its model logs in.

'locales' => [
    'supported' => ConfigHelpers::parseContentLocales(env('LARSASUB_LOCALES', '')),
    'default' => env('LARSASUB_LOCALE_DEFAULT'),
    'fallback' => env('LARSASUB_LOCALE_FALLBACK'),
    'log' => env('LARSASUB_LOG_LOCALE'),
],
Key Default Env Description
supported null LARSASUB_LOCALES null = single-language plan content. A list (nl,en in the env value) stores plan name, description and content per locale
default null LARSASUB_LOCALE_DEFAULT The language untranslated strings are assumed to be in, and where a plain-string write lands when the current locale is not one of supported (otherwise it lands in the current locale). null = the first supported locale (in single-language mode: app.locale). Must be one of supported
fallback null LARSASUB_LOCALE_FALLBACK The read-time fallback; null = app.fallback_locale
log null LARSASUB_LOG_LOCALE The language of every model-log line; null = app.locale as it stands when the package boots

null for supported is single-language mode — nothing changes anywhere. Setting it turns plan content into per-locale translations, resolved to the viewer's locale when read; frozen text (charge, invoice and payment descriptions) is written in the billable's locale. Read Multilingual plan content before enabling it; in particular, never narrow supported while translated content exists. Existing single-language content is migrated with php artisan larsasub:promote-translations {locale}.

Setting default explicitly is recommended. It is deliberately not app.locale, which locale middleware changes per request.

The model-log language

log is the one language every model-log line is written in — the History of a subscription, charge or mandate in Nova or Filament — whoever acted and whatever locale the request or a mailer had set at that moment. null means app.locale as it stands when the package boots, not at the time of writing: webhooks, queue workers and scheduled commands write most of these lines, and middleware and mailers change the locale.

The package's service provider boots before your AppServiceProvider. An App::setLocale() in your provider's boot() — or in middleware — therefore does not change the log language; only config/app.php's locale does. If the History should be in another language, set it here: LARSASUB_LOG_LOCALE, or larsasub.locales.log in your published config. A config published before the key existed has no log key, so the env variable is not read until you add it; larsasub:doctor lists locales.log as missing.

The package ships en and nl. For another language the lines must actually translate: a lang/vendor/larsasub/<locale>/ override counts only when it translates the log groups. Otherwise the boot warns and every line is written in app.fallback_locale — or, when that cannot render them either, stored as its raw key (still re-renderable from the row's meta). larsasub:doctor prints the log language and whether it renders. See HasModelLogs for re-rendering a line in another language.

Terms acceptance

Turn this on when the bundled subscribe flow should make subscribers accept your terms and keep evidence of it.

'terms' => [
    'provider' => env('LARSASUB_TERMS_PROVIDER'),
    'documents' => [],
    'withdrawal_consent' => env('LARSASUB_TERMS_WITHDRAWAL_CONSENT', false),
],
Key Default Env Description
provider null LARSASUB_TERMS_PROVIDER A class implementing Contracts\TermsProvider. null or empty disables the feature
documents [] — The terms versions, read only by the bundled Support\FileTermsProvider
withdrawal_consent false LARSASUB_TERMS_WITHDRAWAL_CONSENT Also require consent to immediate performance and acknowledgement of losing the 14-day withdrawal right

null (default) disables the feature entirely; hosts that form contracts through their own flows keep enforcing and recording consent themselves. Naming a TermsProvider makes the bundled subscribe flow require a terms checkbox and record the acceptance. Boot fails on a provider class that does not exist or does not implement the contract.

Append new versions to documents; never edit an entry that already shipped. withdrawal_consent is read only while a provider is configured: it adds a second checkbox, recorded as a separate acceptance linked to the subscription. See Terms acceptance and Withdrawal consent.

Redirect routes

These keys decide where the customer's browser lands after returning from the payment provider.

'redirect' => [
    'first_payment' => 'larsasub.my-subscription.view',
    'manual_charge_payment' => 'larsasub.my-subscription.view',
    'mandate_setup' => 'larsasub.my-payment-methods.view',
    'connect_callback' => 'larsasub.tenant-connect.view',
    'single_payment' => 'larsasub.my-payments.view',
],

Each key takes one of two shapes: a route name, or an invokable class name.

A route name

The default. The package passes the context as route parameters, so they become path segments where your route declares them and query parameters otherwise:

Key Context Route parameters
first_payment ?RecurringCharge charge, subscription
manual_charge_payment ?RecurringCharge charge
mandate_setup ?RecurringCharge mandate_setup=pending, charge
single_payment ?SinglePayment singlePayment
connect_callback ?Model (the tenant) —

An invokable class name

A value shaped like a namespaced PHP class name (App\Billing\Foo: identifier segments separated by backslashes, a leading one allowed). Nothing else is: larsasub.my-subscription.view is a route name, and so is anything with a character a class name cannot hold, backslash or not. Laravel accepts any string as a route name, including one of that shape, so the rule is "if it could be a class name, it is read as one" — rename a route named that way.

Use it for a host with more than one kind of checkout (an office UI next to an onboarding wizard), where one global route name would have to multiplex on query parameters:

'redirect' => [
    'first_payment' => \App\Billing\FirstPaymentReturnUrl::class,
],
namespace App\Billing;

use PetersDevelopment\LarsaSub\Models\RecurringCharge;

class FirstPaymentReturnUrl
{
    public function __invoke(?RecurringCharge $charge, ?string $origin = null): ?string
    {
        // Return an absolute http(s) URL, or null to use the package fallback.
        if ($charge === null) {
            return null;
        }

        // Routing on the charge is safe; RENDERING it is not — see the note
        // below. `charge` here is the same id the incoming URL already held.
        return $this->startedInOnboarding($charge)
            ? route('onboarding.done', ['charge' => $charge->id])
            : route('office.billing.return', ['charge' => $charge->id]);
    }
}

The page you send them to is where ownership gets checked, the same way any other page of yours does it. If instead you want to decide the destination from something only the owner may know — or put anything from the charge in the URL that was not already in it — verify ownership first and return null when you cannot:

$user = auth()->user();   // null on the return endpoints more often than not

if ($user === null || ! $user->owns($charge)) {
    return null;          // the package falls back to its own chain
}

The failure contract is deliberate: this runs on the page that confirms a payment, so a throw, a relative URL, an empty string or a non-string answer is logged and falls back — never a 500. The chain is invokable → your route name (if registered) → the bundled default (if registered) → config('app.url').

A class name that does not exist, or has no __invoke(), fails at boot. A route name that does not exist is reported by php artisan larsasub:doctor (routes are not loaded yet at boot, so that one cannot be caught there).

Security — what "verify ownership" means here. Your invokable runs in two places, and it cannot tell them apart. One is the subscribe request your own UI made, where the customer is logged in and the package just created the charge it hands you. The other is the four return endpoints, which carry no auth middleware by design — their URLs are embedded in the payment at the provider and a customer coming back from their bank may have no session — and which bind the model from an id in the URL, so anyone may name any charge.

No argument tells the two apart, deliberately. A provenance flag would only be useful to license trusting the model on the first path, and that is a property the package cannot promise for call sites it has not added yet. auth()->user() is not that flag either: it is usually null on the return endpoints, and when it is not, the session user is still no evidence that this charge is theirs.

So the rule is uniform, and it is narrower than "never touch it": route on the model, do not render it. Picking a destination from an unverified charge leaks nothing, and echoing back an id that was already in the incoming URL leaks nothing either. What does leak is amounts, descriptions, billable or provider identifiers — anything the caller did not already have. Need any of that? Then check ownership explicitly (and return null when you cannot), or put it on the destination page, behind your own auth.

connect_callback

connect_callback is resolved only when the tenant owner starts the Mollie Connect flow; the later steps have no tenant yet and keep the plain route name. Its URL must live on the origin the flow started from — the package computes that origin as a security control (an attacker-controlled Host header would otherwise become an open redirect) and passes it as the second argument, so do not derive it yourself. You may return a path, which the package prefixes with that origin. Do not return a fragment: the package appends connect_success=1 / connect_error=... as a query parameter and a fragment would swallow it.

Features

Feature defaults give every plan a baseline of free-form values your application checks ($billable->hasFeature('max_users')). They ship empty.

'features' => [
    'default' => [],   // e.g. ['max_users' => 5]
    'tenant'  => [],   // e.g. ['max_lessons' => 10]
    'labels'  => [],   // e.g. ['max_users' => 'Team members']
],
Key Default Env Description
default [] — Defaults for plans without a tenant: single-tenant apps, or the platform billing its tenants
tenant [] — Defaults for tenant-scoped plans (a tenant billing its users)
labels [] — Display names per feature key, for Nova and the dashboards

Feature keys and values are yours. Resolution order is config defaults → Subscription.features → BillableSubscription.feature_overrides; read them with hasFeature(), featureValue() and featureList() (see Subscriptions).

labels maps keys to display names — plain strings, or translation keys resolved through __(). An unlabelled key shows as the raw key.

Data retention

How long the package keeps its own operational records. Three daily prune commands read these keys.

'retention' => [
    'model_logs_days' => env('LARSASUB_MODEL_LOGS_RETENTION_DAYS', 730),
    'notification_deliveries_days' => env('LARSASUB_NOTIFICATION_DELIVERIES_RETENTION_DAYS', 365),
    'dunning_days' => env('LARSASUB_DUNNING_RETENTION_DAYS'),
],
Key Default Env Pruned by
model_logs_days 730 LARSASUB_MODEL_LOGS_RETENTION_DAYS larsasub:prune-model-logs
notification_deliveries_days 365 LARSASUB_NOTIFICATION_DELIVERIES_RETENTION_DAYS larsasub:prune-notification-deliveries
dunning_days null (kept) LARSASUB_DUNNING_RETENTION_DAYS larsasub:prune-dunning-cases

Any value that is not a positive number — null, 0, empty — disables pruning for that table. Model-log entries and delivery records older than the number of days are deleted in bounded chunks.

The ~2-year model-log default comfortably outlives the SEPA 13-month chargeback window a disputed direct debit needs the trail for. Notification deliveries are kept shorter on purpose: deduplicating a retried job needs minutes of history, and the longer window only serves the question "was that payment reminder actually sent?" — which surfaces within the year. Those rows carry no fiscal trail; model logs do.

dunning_days is null by default: a resolved reminder series documents how a debt was pursued. Only resolved series (paid or closed) are ever pruned, and never one whose charge is still an unpaid debt — deleting it would let the series start over (Reminder series).

Scheduling

LarsaSub's recurring work — planning and collecting charges, ending and renewing subscriptions, reminders, pruning — runs as scheduled artisan commands. This block registers them with Laravel's scheduler and tunes what they do.

'scheduling' => [
    'enabled' => env('LARSASUB_SCHEDULING_ENABLED', true),
    'auto_register' => env('LARSASUB_SCHEDULING_AUTO_REGISTER', true),
    'timezone' => env('LARSASUB_SCHEDULING_TIMEZONE', null),
    // … one *_frequency key per command, and the settings below …
],
Key Default Env Description
enabled true LARSASUB_SCHEDULING_ENABLED false turns every scheduled command into a no-op that exits 0 — including a manual run, except larsasub:check-vat-numbers --unchecked
auto_register true LARSASUB_SCHEDULING_AUTO_REGISTER false keeps the commands but stops LarsaSub registering them, so you schedule them yourself
timezone null LARSASUB_SCHEDULING_TIMEZONE Timezone for the auto-registered commands; null = the app timezone

With enabled off, recurring billing stops: no charge is planned or collected, and larsasub:doctor says so. The full per-command treatment lives in Scheduled commands.

Command frequencies

One key per scheduled command. Only the names of these Laravel scheduler methods are understood: everyMinute, everyTwoMinutes, everyThreeMinutes, everyFourMinutes, everyFiveMinutes, everyTenMinutes, everyFifteenMinutes, everyThirtyMinutes, hourly, everyTwoHours, everyThreeHours, everyFourHours, everySixHours, daily, twiceDaily, weekly, monthly.

A misspelled frequency is silent. Anything else registers as that command's own default — the value in the table below, not daily — without a warning. A mistyped cleanup_unused_billable_subscriptions_frequency runs twiceDaily, a mistyped validate_mandates_frequency runs weekly.

Key Default Env Command
plan_charges_frequency daily LARSASUB_PLAN_CHARGES_FREQUENCY larsasub:plan-charges
execute_charges_frequency daily LARSASUB_EXECUTE_CHARGES_FREQUENCY larsasub:execute-charges
end_subscriptions_frequency daily LARSASUB_END_SUBSCRIPTIONS_FREQUENCY larsasub:end-subscriptions
detect_started_frequency daily LARSASUB_DETECT_STARTED_FREQUENCY larsasub:detect-started-subscriptions
end_billable_subscriptions_frequency daily LARSASUB_END_BILLABLE_SUBSCRIPTIONS_FREQUENCY larsasub:end-billable-subscriptions
renew_billable_subscriptions_frequency daily LARSASUB_RENEW_BILLABLE_SUBSCRIPTIONS_FREQUENCY larsasub:renew-billable-subscriptions
cleanup_unused_billable_subscriptions_frequency twiceDaily LARSASUB_CLEANUP_UNUSED_BILLABLE_SUBSCRIPTIONS_FREQUENCY larsasub:cleanup-unused-billable-subscriptions
announce_renewal_billable_subscriptions_frequency daily LARSASUB_ANNOUNCE_RENEWAL_BILLABLE_SUBSCRIPTIONS_FREQUENCY larsasub:announce-renewal-billable-subscriptions
notify_charges_frequency daily LARSASUB_NOTIFY_CHARGES_FREQUENCY larsasub:notify-charges
process_expired_credits_frequency daily LARSASUB_PROCESS_EXPIRED_CREDITS_FREQUENCY larsasub:process-expired-credits
notify_expiring_credits_frequency daily LARSASUB_NOTIFY_EXPIRING_CREDITS_FREQUENCY larsasub:notify-expiring-credits
process_dunning_frequency daily LARSASUB_PROCESS_DUNNING_FREQUENCY larsasub:process-dunning (a no-op while the reminder series is off)
prune_model_logs_frequency daily LARSASUB_PRUNE_MODEL_LOGS_FREQUENCY larsasub:prune-model-logs
prune_notification_deliveries_frequency daily LARSASUB_PRUNE_NOTIFICATION_DELIVERIES_FREQUENCY larsasub:prune-notification-deliveries
prune_dunning_cases_frequency daily LARSASUB_PRUNE_DUNNING_CASES_FREQUENCY larsasub:prune-dunning-cases
sync_settlements_frequency daily LARSASUB_SYNC_SETTLEMENTS_FREQUENCY larsasub:sync-settlements
check_vat_numbers_frequency twiceDaily LARSASUB_CHECK_VAT_NUMBERS_FREQUENCY larsasub:check-vat-numbers (a no-op while VAT number validation is off)
validate_mandates_frequency weekly LARSASUB_VALIDATE_MANDATES_FREQUENCY larsasub:validate-mandates

plan_charges_frequency must run every day, at or before the first execute-charges run. A subscription charge is planned on its collection day, and plan-charges is registered before execute-charges, so on the shipped daily both run at midnight and the charge planned then is collected in the same tick. weekly or monthly collect up to six days or a month late; twiceDaily (01:00 and 13:00) against a daily execute run (00:00) collects every charge a day late. Boot warns about those combinations and larsasub:doctor lists them — both only while auto_register is on, because otherwise your own scheduler decides and the package cannot read it. renew_billable_subscriptions_frequency must run at or before the planner's first run each day (daily and anything more frequent from midnight do). A twiceDaily renewal (01:00 and 13:00) is late beside a daily planner, which plans the renewed term the next day, and beside a daily execute-charges, which collects it the next day; weekly or monthly wait for their next run. Boot warns about those as well, as does larsasub:doctor, again only while auto_register is on.

execute_charges_frequency is also half of a safety net — see Reconciliation.

Command settings

Key Default Env Description
cleanup_unused_billable_subscriptions_hours 12 LARSASUB_CLEANUP_UNUSED_BILLABLE_SUBSCRIPTIONS_HOURS How long a never-activated subscription whose first payment failed is kept before cleanup-unused-billable-subscriptions deactivates it. The first-payment-failed mail names this window
notify_charges_days_before 10 LARSASUB_NOTIFY_CHARGES_DAYS_BEFORE How many days before its planned date notify-charges announces a charge created with notify = true. No charge the package generates itself is announced this way — it serves charges you create (createStandaloneCharge() announces by default)
notify_credit_expiration_days_before 14 LARSASUB_NOTIFY_CREDIT_EXPIRATION_DAYS_BEFORE How many days before credit expires notify-expiring-credits warns the subscriber
validate_mandates_delay_ms 100 LARSASUB_VALIDATE_MANDATES_DELAY_MS Pause between two provider calls of validate-mandates
validate_mandates_limit null LARSASUB_VALIDATE_MANDATES_LIMIT Per-run cap for the scheduled validate-mandates. Only a positive whole number is honoured; anything else uses the command's built-in 5000. There is no unbounded mode: each run continues with the least recently validated mandates, so a larger fleet is covered over successive runs
check_vat_numbers_delay_ms 500 — Pause between two VIES calls of check-vat-numbers (VIES caps concurrent requests and blocks abusive IPs). Not a number reads as no pause, with a boot warning
check_vat_numbers_limit null — How many registrations one check-vat-numbers run takes up; empty = all that are due. Not a positive number reads as no limit, with a boot warning
sync_settlements_lookback_days 60 LARSASUB_SYNC_SETTLEMENTS_LOOKBACK_DAYS How many days of payouts each sync-settlements run looks back over. Not a positive number reads as 60, with a boot warning

The execution limit

Key Default Env Description
execute_charges_limit null (no limit) LARSASUB_EXECUTE_CHARGES_LIMIT The most charges one larsasub:execute-charges run takes up

The limit is one budget across both collection loops (subscription and standalone charges), counting every charge the run takes up — including the ones it skips. Leftovers keep their past planned_on and are collected by the next run, so lowering it spreads the work out and never drops a charge. null, empty or any non-positive value means no limit, which is the default.

Do not set a cap without sizing it. Capacity is limit × runs per day, and larsasub:end-billable-subscriptions is not limited: if execution lags past a subscription's end_date (a fixed term without renewal, or a cancellation), that subscription is deactivated first and its last charge is then parked with needs_attention instead of collected — and a charge on an inactive subscription is not payable by the customer either. A too-low limit is a money bug, not a performance knob. Full explanation: Limiting one execution run.

Each pass before the collection loops takes its own slice of this limit: mandate recovery, the reconciliation of unresolved charges and of unresolved single payments, the look for reversed chargebacks, and the reconciliation of stranded chargeback follow-ups. A limit of 50 is therefore up to five budgets of 50 records plus 50 collections, not 50 calls in total. And a record is not a call: a reversal look costs two provider requests, and a mandate repair at least two and usually more, because adopting a mandate walks the customer's mandate list. See SCHEDULING.md.

Reconciliation

A charge can end up claimed but unresolved when a run is killed between claiming it and storing the provider's answer. larsasub:execute-charges asks Mollie what happened to such claims; these keys tune that.

Key Default Env Description
reconcile_unresolved_after_minutes 30 LARSASUB_RECONCILE_UNRESOLVED_AFTER_MINUTES How old a claim must be before the executor asks about it. Clamped to 1–59
reconcile_lookup_ceiling 500 LARSASUB_RECONCILE_LOOKUP_CEILING How many of a customer's payments one reconciliation walks before it gives up
reconcile_park_after_hours 24 LARSASUB_RECONCILE_PARK_AFTER_HOURS How long a claim may keep failing reconciliation before it is parked with needs_attention. At least 1

reconcile_unresolved_after_minutes — values of 60 or more are clamped to 59: reconciliation releases a charge it cannot find at the provider and the executor retries it, which is only safe while Mollie still holds the idempotency key from the first attempt — and it holds those for one hour. The clamp bounds this threshold only. The age a claim really reaches is this value plus the execute_charges_frequency interval, so keeping the safety net means scheduling execute-charges often enough for the sum to stay under 60. The shipped daily does not (30 + 1440), and neither does hourly (90); everyThirtyMinutes lands exactly on 60, which is not under it.

The package computes that sum for you. larsasub:doctor prints it as a Reconciliation window line — threshold plus interval, with the threshold as it will be applied (clamped) rather than as configured — and ConfigValidator logs a warning while it is 60 or more. On the shipped defaults that warning fires (30 + daily = 1470): either schedule execute-charges at everyFifteenMinutes or finer, or take it as read that the retry-dedup net is not there. That is a defensible choice — the net only matters for a run that dies between claiming a charge and storing the answer — as long as it is a choice. A frequency the scheduler does not recognise is judged as the default it is registered as. Both go quiet when scheduling.auto_register is off, because then your own scheduler decides the interval and the package cannot read it — that case is yours to check.

reconcile_lookup_ceiling — a history of exactly that many payments is still walked in full; one payment more stops the walk and parks the charge with needs_attention rather than releasing it: "we stopped looking" is not "there is nothing there", and releasing would charge twice.

reconcile_park_after_hours is the grace period a record gets before reconciliation gives up on it. It is measured from the claim itself (charged_at on a charge; updated_at on a single payment and on a chargeback follow-up, whose charged_at is empty on a first attempt and the first attempt's on a retry), not from the first failure. While the claim is younger than this, a reconciliation that cannot resolve it logs and defers, and the record stays eligible for the next run. Older than this, it is parked with needs_attention in front of an operator.

Parking on the first failure is what this avoids. Parking removes a record from every future run, so a transient hiccup — Mollie briefly unreachable, a tenant database restarting — would otherwise orphan the claim permanently: its webhook cannot match a row that has no provider id, and the reconciler would never come back for it.

Mandate recovery

Key Default Env Description
recover_mandates_within_days 30 LARSASUB_RECOVER_MANDATES_WITHIN_DAYS How far back a PAID first payment may be and still have its missing mandate re-registered by larsasub:execute-charges

The shape: the payment succeeded, the mandate registration behind it threw, the charge was flagged — money in, subscription active, nothing to collect the next period with. Each run retries the registration, and a mandate recovered that way is used by the same run. Past the window the state is left in front of an operator rather than retried forever.

It is not an off switch, and the values that look like one are the trap. null and '' mean the default of 30; anything else is read as an integer and raised to at least 1 — so 0, false and any non-numeric value leave the pass running, creating mandates at the provider and mailing the subscriber, with a window of one day. Close enough to off to be mistaken for it, which is why ConfigValidator warns about those values instead of honouring them. Only a positive number of days belongs here. Nor is there a way to keep collection without the pass: it runs first in every larsasub:execute-charges run, and RecurringChargeService::scheduleExecute() is the only public entry to the collection loops. Keep the window small instead.

The retry can create a mandate at the provider, not only adopt one that is already there. On the EU and mandate-setup flows only the local registration failed, so it is an adoption; on the NL flow LarsaSub itself makes the mandate from the IBAN of the first iDEAL payment, so a registration that never completed left nothing to adopt and creating one is the only repair available. The subscriber gets the ordinary "payment method added" mail for it.

That is why recovery keys on that failure's own marker and on nothing else: a charge a customer repaired by hand with iDEAL fits every symptom, and repairing it would create a SEPA direct-debit mandate they never authorized. A subscription that is already deactivated is skipped, and a payment method the customer removed is never brought back either. Full explanation: A paid first payment without a mandate.

The overlap mutex

Key Default Env Description
mutex_expires_after_minutes 60 LARSASUB_SCHEDULING_MUTEX_EXPIRES_AFTER_MINUTES How long a scheduled command's overlap mutex is held before it is considered stale. Non-positive falls back to 60

Every auto-registered command runs withoutOverlapping(). The expiry is a stale-lock cleanup, not a guarantee — a run that outlives it loses its slot and the next tick starts alongside it. That costs contention and duplicate work, not double billing (the execution claim catches that); raise it when runs legitimately take longer than an hour. The mutex lives in the default cache store, so on multiple app servers that store must be shared (redis, memcached, database) or it protects nothing between servers. See Overlap protection.

Policies

Authorization for LarsaSub's models. Policies are always enforced in user context (controllers, API routes, admin panels) and never in system context (scheduled commands, service methods).

'policies' => [
    'admin_role' => ConfigHelpers::parseAdminRole(env('LARSASUB_ADMIN_ROLE', '')),
    'models' => [ /* per model, below */ ],
],
Key Default Env Description
admin_role '' (nobody) LARSASUB_ADMIN_ROLE Who counts as a LarsaSub admin — see below
models see below — Per-model permissions

Policies decide the same way on every route, admin panel or not.

For behaviour a config map cannot express, bind your own policy class for a LarsaSub model with Gate::policy().

The admin role

admin_role accepts four forms:

Form Example
Role name 'admin' — Spatie Permission's hasRole() when available; otherwise an is_admin attribute (for admin), an is_<role> attribute, a role column, or a roles relation
Email list 'admin@example.com,super@example.com' — in the env value, anything with @ or a comma is read as addresses (an array in the config file), matched exactly against the user's email
Callback fn ($user) => $user->is_super_admin — breaks config:cache
Invokable class \App\Support\IsLarsaSubAdmin::class — __invoke(?\Illuminate\Contracts\Auth\Authenticatable $user): bool, survives config:cache

The invokable runs in the request, so reading request state there is fine — unlike the notifications.*_url hooks, which run in the queue worker.

With no admin role configured, nobody is an admin. That is the default. In simple mode the management dashboards (plans, subscribers, charges, invoices) authorize through admin_role, so nobody passes and a denied request logs an explanatory line. Boot warns while it is empty.

Per-model permissions

Each model key takes admin_only, ownership_required and an actions map. An action resolves in this order:

  1. An entry in actions wins: true, false, 'admin', 'owner_or_admin', 'owner' or 'authenticated'. Any other value denies, with a log line.
  2. Otherwise admin_only => true makes it 'admin'.
  3. Otherwise ownership_required => true makes viewAny and view 'owner_or_admin'.
  4. Otherwise the action is allowed.

A model key removed from a published config denies every action.

'billable_subscription' => [
    'ownership_required' => true,
    'actions' => [
        'viewAny' => 'owner_or_admin',
        'view' => 'owner_or_admin',
        'create' => false,
        'update' => 'admin',
        'subscribe' => true,
        'cancel' => 'owner_or_admin',
        'terminate' => 'owner_or_admin',
        'retryPayment' => 'owner_or_admin',
        'changeSubscription' => 'owner_or_admin',
        'changePaymentMethod' => 'owner_or_admin',
    ],
],

The shipped defaults, per model key (delete, forceDelete, restore and replicate are false wherever an actions map is shipped, except where listed):

Model key Shipped default
subscription admin_only
billable_subscription As above; delete and the other destructive actions false
payment_method admin_only
recurring_charge ownership_required; viewAny/view owner or admin, update admin, create false
single_payment Same as recurring_charge
billable_credit ownership_required; viewAny/view owner or admin, create/update/addCredit admin, purchase = LARSASUB_SELF_SERVICE_CREDITS (default false)
credit_transaction ownership_required; viewAny/view owner or admin, read-only
model_log admin_only, read-only
payment_provider_customer admin_only, read-only
payment_provider_mandate ownership_required; viewAny/view owner or admin, create authenticated, delete owner or admin, update false
mollie_connected_account admin_only, read-only (managed through OAuth)
invoice ownership_required; viewAny/view/download owner or admin, read-only
credit_note Same as invoice
dunning_case admin_only, read-only; close admin (ends a customer's reminder series)
dunning_step admin_only, read-only

One action has its own environment variable: LARSASUB_SELF_SERVICE_CREDITS is the value of policies.models.billable_credit.actions.purchase — whether subscribers may buy credit for themselves from the credit dashboard. Everything else under policies.models is config-file only.

Notifications

Who receives the package's mails, where their buttons point, what they carry, and which ones are sent at all.

'notifications' => [
    'sales' => ConfigHelpers::parseNotificationRecipients(env('LARSASUB_NOTIFICATIONS_SALES', '')),
    'payment_url' => env('LARSASUB_NOTIFICATIONS_PAYMENT_URL'),
    'first_payment_url' => env('LARSASUB_NOTIFICATIONS_FIRST_PAYMENT_URL'),
    'user_app_url' => env('LARSASUB_NOTIFICATIONS_USER_APP_URL'),
    'tenant_admin_url' => env('LARSASUB_NOTIFICATIONS_TENANT_ADMIN_URL'),
    'tenant_model_url' => env('LARSASUB_NOTIFICATIONS_TENANT_MODEL_URL'),
    'attachments' => env('LARSASUB_NOTIFICATIONS_ATTACHMENTS', false),
    'user' => [ /* per-notification toggles */ ],
    'company' => [ /* per-notification toggles */ ],
    'disabled_listeners' => [],
],
Key Default Env Description
sales '' (nobody) LARSASUB_NOTIFICATIONS_SALES Recipients of company mails
payment_url null LARSASUB_NOTIFICATIONS_PAYMENT_URL The pay button of the payment-failed, chargeback and reminder mails
first_payment_url null LARSASUB_NOTIFICATIONS_FIRST_PAYMENT_URL The retry button of the first-payment-failed mail
user_app_url null LARSASUB_NOTIFICATIONS_USER_APP_URL The "go to :appName" button under every subscriber mail
tenant_admin_url null LARSASUB_NOTIFICATIONS_TENANT_ADMIN_URL Connect: the admin button in company mails, for tenant admins
tenant_model_url null LARSASUB_NOTIFICATIONS_TENANT_MODEL_URL Connect: the per-record deep link in company mails, for tenant admins
attachments false LARSASUB_NOTIFICATIONS_ATTACHMENTS Files sent along with the mails
user see below — Per-notification switches for subscriber mails
company see below per key Per-notification switches for company mails
disabled_listeners [] — Notification listener classes to switch off

Every URL hook and the attachments hook run in the queue worker, not in a request. All context must come from the argument the invokable receives. It must not read auth(), session(), request() or a container-bound "current tenant" singleton. billable.owner_resolver documents exactly the opposite (request-scoped) pattern on purpose; copying it here yields an empty or wrong tenant. Anything an invokable throws is caught and logged, and the mail falls back — a button is not a money path. Name a class, never a closure: a closure does not survive config:cache.

A value that reads as a class name but is not an existing invokable class is a boot failure for every URL key and for attachments.

Sales recipients

notifications.sales accepts a role name (requires spatie/laravel-permission), a comma-separated email list, a single email, or an empty value to disable.

An address is matched case-insensitively to a user of auth.providers.users.model. One without a user account is still mailed, on an on-demand mail route — which only the mail channel can take (see Notification channels).

In Connect mode a company mail about a tenant's subscriber goes to that tenant's admins instead; the platform's sales recipients get it as well only with connect.notifications.also_notify_platform on, or when no tenant recipient could be reached.

Leaving sales empty discards every company notification that has no tenant to go to — silently, since there is nobody to send them to. Boot warns while it is empty.

Payment button URLs

payment_url decides where the pay button of three mails points: the payment-failed mail; the chargeback mail, where the charge it receives is the chargeback's follow-up charge — the debt ($charge->isChargebackFollowUp() is true, usually charged_at NULL) — never the charged-back original, which is paid; and the dunning reminder, whose charge is the debt the series chases (a failed charge, possibly a standalone one without a subscription, or a follow-up).

first_payment_url is the retry button of the first-payment-failed mail. It is a separate key on purpose: retrying a failed first payment is a subscription action (retry, or terminate the never-activated subscription), not a payment-page action. Point it at the page where your app offers that retry.

Both take three forms:

Form Behaviour
null (default) The bundled dashboard when its routes are registered: payments for payment_url, subscription for first_payment_url. Under Connect the mail leaves that button out while the charge's tenant drains a disconnect, because the dashboard refuses the payment or retry then
Absolute URL https://app.example/billing/pay/{charge} — {charge} is the charge id; first_payment_url also takes {subscription}. A template using {subscription} on a charge without a subscription is discarded (logged) rather than rendered with a hole
Invokable class name __invoke(\PetersDevelopment\LarsaSub\Models\RecurringCharge $charge): ?string; returning null falls back. Context comes from $charge->billable, $charge->billableSubscription, $charge->billable?->resolveTenant()

The invokable is the form for multi-tenant hosts whose pay page lives on a per-tenant host, where one static URL cannot serve every subscriber.

Leaving both empty only works while their dashboard is registered. With the payments dashboard off, an empty payment_url leaves the payment-failed, chargeback and reminder mails without a pay button — only the generic app button remains — and with the subscription dashboard off, an empty first_payment_url leaves the first-payment-failed mail with no action button at all. Boot warns for exactly that combination, and also when explicit billable mode, the reminder series and an empty payment_url meet: the payments dashboard shows the signed-in user's billable, not necessarily the one the mail is about.

The subscriber button

user_app_url decides where the "go to :appName" button under every subscriber mail points — activation, cancellation, change, the upcoming-collection notice, credit, the payment receipt. The two keys above put an action button in one specific mail; this one sets the default destination of all the rest, and an action button always keeps its own URL.

Set it whenever a subscriber's environment lives somewhere other than config('app.url'). Under Connect a user mail already carries the tenant's name and colours, and this is what makes its button follow the tenant too. But nothing about the key is gated on Connect: a host with per-tenant hostnames and no Connect — the platform billing its tenants, each on its own domain — is the same case, and usually the one where app.url is the marketing site. Only a genuinely single-hostname app has nothing to set here.

Form Behaviour
null (default) config('app.url')
Absolute URL Legal, rarely what you want: every subscriber of every tenant lands on the same host. No placeholders
Invokable class name __invoke(\PetersDevelopment\LarsaSub\Models\Billable $billable): ?string; returning null falls back to config('app.url')

The billable, not the tenant: it is what every user mail has, and the tenant follows from it ($billable->resolveTenant()) while the reverse does not. A mail without a billable never calls the invokable.

Two things it deliberately does not touch. Company mails: those go to the tenant's admins and the platform's sales recipients at once, and for the second config('app.url') is the correct answer — tenant_admin_url is where the first half is answered. And the footer line: "sent by <platform>" names the platform (config('app.name'), separate from the tenant-aware app name above), so it is an attribution rather than a way back.

Boot warns about a value that is neither an absolute http(s) URL nor a class name (a template such as https://{tenant}.example is ignored: nothing is substituted), and about a value that is not a string at all — a closure, most likely.

Published emails/layout.blade.php? The button there reads $appUrl, and this key arrives as a new $userAppUrl variable — so setting it does nothing until you port that one line. Nothing errors and nothing logs; larsasub:doctor is what catches it, and UPGRADE.md holds the diff.

Tenant admin buttons

Both keys are Connect-only and concern company mails read by a tenant's own admins. What neither ever falls back to is the platform's own URL — the one address a tenant must not be sent — so the mail carries one fewer button instead.

tenant_admin_url is where the "go to admin" button points for the tenant's admins. The platform's admin URL comes from the admin-panel package; a tenant's cannot. The chain is: this key, then a resolver registered for the tenant audience, then the first registered bundled tenant dashboard of subscribers, charges, subscriptions and connect (in that order), then no admin button at all. That dashboard fallback resolves with route() against app.url, so set this key even when you run the bundled dashboards if your tenants have their own domains. See The admin button knows who is reading.

tenant_model_url is the same idea one level down: where a tenant views one record, for the "view payment" / "view subscription" deep link. Unset is a supported state: the mail keeps its single admin button. See The deep link to one record.

Form tenant_admin_url tenant_model_url
null (default) The chain above No deep link for tenant admins
Absolute URL E.g. https://admin.example.com/billing. No placeholders Legal, but every deep link then points at the same page
Invokable class name __invoke(?\Illuminate\Database\Eloquent\Model $tenant): ?string __invoke(\Illuminate\Database\Eloquent\Model $model): ?string — the model the mail is about (SinglePayment, RecurringCharge or BillableSubscription); return null for anything you have no page for

Returning null falls through to a resolver registered for the tenant audience, and then to the next step of the chain. Boot warns about a value that is neither an absolute URL nor a class name, about a non-string value, and — for tenant_admin_url — when Connect is on, the key is empty and those four tenant dashboards are all off, which leaves tenant admins without any admin button.

Attachments

notifications.attachments decides whether the package's mails carry a file. Most of these mails are signals, so nothing is attached by default.

Form Behaviour
false (default) Nothing is attached
'invoice' The invoice LarsaSub generated for the record the mail is about, when there is one; silent when there is not. Needs barryvdh/laravel-dompdf (boot warns without it), and declines when you bound your own InvoiceRenderer
Invokable class name __invoke(string $notification, ?\Illuminate\Database\Eloquent\Model $record): Attachment[]|Attachment|null, receiving the notification's class name and the payment or charge the mail reports (on the chargeback mails, the charged-back original; null for mails without one). Return [] for everything you have no file for

The invokable runs in the queue worker, inside toMail(); anything thrown costs the attachment rather than the mail. With 'invoice', turning on notifications.user.payment_paid is what gives a recurring mail to hang the invoice on — otherwise only the activation mail carries one. See Attachments.

Notification toggles

notifications.user and notifications.company switch individual mails on and off. Turning a user key off is the handover between "the package mails your customer" and "you do": with it off nothing is sent, so nobody is mailed unless you mail them. See Notifications for which event drives each one.

All default to on, with eight exceptions: the three company credit notices; user.payment_paid and company.recurring_charge_paid — the receipt for a collection that simply worked, which on a continuous subscription is a mail every billing period (see Notifications); user.subscription_started and company.subscription_started, the upcoming→active notice only hosts with deferred starts ever see (see Notifications); and company.dunning_reminder, a copy of every reminder step.

The user toggles are config-file only:

Key Default Mail
subscription_activated true A subscription was activated
subscription_started false A subscription activated with a future start date reached it (a trial ending, a deferred start). Scheduler-only
subscription_cancelled true A subscription was cancelled
subscription_ended true A subscription ended
subscription_renewed true A subscription renewed
subscription_renewal_announced true A renewal is coming up
subscription_changed true The plan changed
subscription_cost_changed true Cost lines changed (one mail per act)
subscription_vat_rate_changed true The plan's VAT rate changed the debited amount
payment_paid false Receipt for a recurring collection. Never for the first payment or a mandate verification
payment_failed true A collection failed
payment_canceled true A payment was canceled
payment_expired true A payment expired
first_payment_failed true The first payment failed
chargeback true A payment was charged back — the customer's only notice of a follow-up charge
refund true A refund was made
charge_announced true An upcoming charge created with notify = true
dunning_reminder true One mail per reminding step of the reminder series
credit_added true Credit was added
credit_expiring true Credit is about to expire
credit_expired true Credit expired
single_payment_paid true A one-time payment was paid
single_payment_failed true A one-time payment failed
payment_method_added true A payment method was added
payment_method_removed true A payment method was removed
payment_method_unusable true A mandate became unusable at the bank

The company toggles each have an environment variable:

Key Default Env
subscription_activated true LARSASUB_NOTIFY_COMPANY_SUBSCRIPTION_ACTIVATED
subscription_started false LARSASUB_NOTIFY_COMPANY_SUBSCRIPTION_STARTED
subscription_cancelled true LARSASUB_NOTIFY_COMPANY_SUBSCRIPTION_CANCELLED
subscription_changed true LARSASUB_NOTIFY_COMPANY_SUBSCRIPTION_CHANGED
recurring_charge_paid false LARSASUB_NOTIFY_COMPANY_CHARGE_PAID
payment_failed true LARSASUB_NOTIFY_COMPANY_PAYMENT_FAILED
chargeback true LARSASUB_NOTIFY_COMPANY_CHARGEBACK
refund true LARSASUB_NOTIFY_COMPANY_REFUND
recurring_charge_canceled true LARSASUB_NOTIFY_COMPANY_CHARGE_CANCELED
recurring_charge_expired true LARSASUB_NOTIFY_COMPANY_CHARGE_EXPIRED
dunning_reminder false LARSASUB_NOTIFY_COMPANY_DUNNING_REMINDER
credit_added false LARSASUB_NOTIFY_COMPANY_CREDIT_ADDED
credit_expiring false LARSASUB_NOTIFY_COMPANY_CREDIT_EXPIRING
credit_expired false LARSASUB_NOTIFY_COMPANY_CREDIT_EXPIRED
single_payment_paid true LARSASUB_NOTIFY_COMPANY_SINGLE_PAYMENT_PAID
single_payment_failed true LARSASUB_NOTIFY_COMPANY_SINGLE_PAYMENT_FAILED
single_payment_chargeback true LARSASUB_NOTIFY_COMPANY_SINGLE_PAYMENT_CHARGEBACK
single_payment_refund true LARSASUB_NOTIFY_COMPANY_SINGLE_PAYMENT_REFUND
payment_needs_attention true LARSASUB_NOTIFY_COMPANY_PAYMENT_NEEDS_ATTENTION
connect_connection_lost true LARSASUB_NOTIFY_COMPANY_CONNECT_CONNECTION_LOST
connect_disconnected_open_payments true LARSASUB_NOTIFY_COMPANY_CONNECT_DISCONNECTED_OPEN_PAYMENTS
mandate_registration_failed true LARSASUB_NOTIFY_COMPANY_MANDATE_REGISTRATION_FAILED

A toggle that is not a boolean is read the way PHP's boolean filter reads it: true, 1, yes and on are on, anything else — an empty value included — is off. Boot warns about each non-boolean toggle and says how it reads. Boot also warns when chargeback follow-ups are on while company.payment_needs_attention or user.chargeback is off, and when the reminder series reminds customers while user.dunning_reminder is off.

Disabled listeners

disabled_listeners takes fully qualified listener class names, for replacing one default listener with your own without turning its toggle off for everyone — notification listeners only (the Send*Listener classes):

'disabled_listeners' => [
    \PetersDevelopment\LarsaSub\Listeners\BillableSubscription\SendSubscriptionActivatedToUserListener::class,
],

Any other class there does nothing, and the boot check and larsasub:doctor warn about it. The package's automatic settlement of a double-paid chargeback follow-up is switched off with dunning.double_paid = 'signal', not here.

Notification channels

Which channels the package's notifications are sent through, globally and per notification class.

'notification_channels' => [
    'default' => ConfigHelpers::parseNotificationChannels(env('LARSASUB_NOTIFICATION_CHANNELS', 'mail')),
    'overrides' => [
        // \PetersDevelopment\LarsaSub\Notifications\RecurringCharge\Company\RecurringChargeFailedNotification::class => ['mail', 'database'],
    ],
],
Key Default Env Description
default ['mail'] LARSASUB_NOTIFICATION_CHANNELS Channels for every notification; comma-separated in the env value. Empty = mail
overrides [] — Notification class name → channel list, consulted before default

Only mail and database (which needs Laravel's notifications table) reach a package notification. A notification goes out only on the channels it has a method for: every package notification has toMail(), and the company notifications — all but the credit-expiring and credit-expired ones — also toDatabase(). Any other channel in the list (slack, broadcast, a custom class) is skipped for that notification, with a log warning.

The database channel is safe with per-tenant databases: Laravel writes that row through the notifiable's own relation. A channel that persists through its own model is not — see Multi-database tenancy.

Boot warns when a channel list holds only model-bound channels (database, broadcast) while your owner model has no Notifiable and is mailed at its billable_email address: such an owner cannot receive those mails at all.

Company mail has the same limit. An address in notifications.sales without a user account gets company mail on an on-demand mail route, so a channel list a company notification reads — the default, or an override for a company class — without mail drops every one of those mails. larsasub:doctor names the addresses and the lists; boot cannot, because telling an address from a user account takes a query.

Standalone dashboards

The bundled Vue dashboards: how their routes are registered and protected, which ones exist, and how they look. Full treatment in Standalone dashboards.

'standalone' => [
    'middleware' => ConfigHelpers::parseMiddleware(env('LARSASUB_STANDALONE_MIDDLEWARE'), ['web', 'auth', 'verified']),
    'guard' => env('LARSASUB_STANDALONE_GUARD'),
    'domain' => env('LARSASUB_STANDALONE_DOMAIN'),
    'page_size' => env('LARSASUB_STANDALONE_PAGE_SIZE', 50),
    'list_limit' => env('LARSASUB_STANDALONE_LIST_LIMIT', 500),
    'dashboards' => [ /* twelve switches, all true */ ],
    'url' => [ /* one prefix per dashboard */ ],
    'show_back' => env('LARSASUB_STANDALONE_SHOW_BACK', true),
    'back' => env('LARSASUB_STANDALONE_BACK_URL', '/'),
    'theme_view' => env('LARSASUB_STANDALONE_THEME_VIEW'),
],

Routes and authentication

Key Default Env Description
middleware ['web', 'auth', 'verified'] LARSASUB_STANDALONE_MIDDLEWARE The complete middleware stack of every dashboard route; comma-separated in the env value
guard null LARSASUB_STANDALONE_GUARD The auth guard the package reads the current user from; null = the default guard
domain null LARSASUB_STANDALONE_DOMAIN Route domain for the dashboards, e.g. billing.example.com; null = no constraint

standalone.middleware is the COMPLETE stack. What you put here is what the routes get; the package wraps nothing around it, the same way Nova, Horizon and Telescope treat their own middleware key. Keep web unless your guard is stateless — it carries the session and CSRF verification, and without it the dashboards still render while every action (cancel, change, pay, save profile) answers 419. Replace the list for your own auth middleware, to drop email verification, or to add a tenant-resolution step. An empty value keeps the default rather than publishing billing pages openly.

standalone.guard names the guard the package reads the current user from. When you point it at a non-default guard, authenticate against that guard in the middleware too (auth:billing) — otherwise the route authenticates one user and the package reads another, which renders fine and denies every action. ConfigValidator warns at boot and larsasub:doctor reports it.

standalone.domain constrains the dashboards to one host. It also decides the origin a Mollie Connect flow returns to: without it, that origin comes from the request's Host header, which is only safe behind Laravel's TrustHosts middleware (the package logs a warning when neither is in place).

The same middleware and domain also apply to the Mollie Connect authorize, complete, disconnect and cancel-disconnect routes. Three groups of routes are deliberately not covered by these keys: the provider webhooks (unauthenticated by design — the provider calls them), the Connect OAuth callback (registered at Mollie and built from app.url; it only hands over to the protected complete route, which checks the user), and the return endpoints after a payment (plain web, because their URLs are embedded in payments at the provider and a returning customer may have no session).

Dashboards and URLs

Each dashboard has an on/off switch under standalone.dashboards and a URL prefix under standalone.url. All twelve default to on; switching one off unregisters its routes entirely.

dashboards key Switch env url key Default URL URL env
subscription LARSASUB_DASHBOARD_SUBSCRIPTION subscription-dashboard billing/dashboard LARSASUB_STANDALONE_SUBSCRIPTION_URL
payments LARSASUB_DASHBOARD_PAYMENTS payments-dashboard billing/payments LARSASUB_STANDALONE_PAYMENTS_URL
payment_methods LARSASUB_DASHBOARD_PAYMENT_METHODS payment-methods-dashboard billing/payment-methods LARSASUB_STANDALONE_METHODS_URL
credit LARSASUB_DASHBOARD_CREDIT credit-dashboard billing/credit LARSASUB_STANDALONE_CREDIT_URL
profile LARSASUB_DASHBOARD_PROFILE profile-dashboard billing/profile LARSASUB_STANDALONE_PROFILE_URL
invoices LARSASUB_DASHBOARD_INVOICES invoices-dashboard billing/invoices LARSASUB_STANDALONE_INVOICES_URL
tenant_subscriptions LARSASUB_DASHBOARD_TENANT_SUBSCRIPTIONS tenant-subscriptions-dashboard billing/admin/subscriptions LARSASUB_STANDALONE_TENANT_SUBSCRIPTIONS_URL
tenant_subscribers LARSASUB_DASHBOARD_TENANT_SUBSCRIBERS tenant-subscribers-dashboard billing/admin/subscribers LARSASUB_STANDALONE_TENANT_SUBSCRIBERS_URL
tenant_charges LARSASUB_DASHBOARD_TENANT_CHARGES tenant-charges-dashboard billing/admin/charges LARSASUB_STANDALONE_TENANT_CHARGES_URL
tenant_invoices LARSASUB_DASHBOARD_TENANT_INVOICES tenant-invoices-dashboard billing/admin/invoices LARSASUB_STANDALONE_TENANT_INVOICES_URL
tenant_connect LARSASUB_DASHBOARD_TENANT_CONNECT tenant-connect-dashboard billing/admin/connect LARSASUB_STANDALONE_TENANT_CONNECT_URL
tenant_fees LARSASUB_DASHBOARD_TENANT_FEES tenant-fees-dashboard billing/admin/fees LARSASUB_STANDALONE_TENANT_FEES_URL

Two switches are not enough on their own:

The tenant_* management dashboards are mode-aware: under Connect they are the tenant owner's view of their own tenant; in simple mode they are operator management over the global plans, authorized through policies.admin_role. Turning off the payments or subscription dashboard affects the mail buttons that fall back to them — see Payment button URLs.

Lists, back button and theme

Key Default Env Description
page_size 50 LARSASUB_STANDALONE_PAGE_SIZE Page size of the paginated lists (invoices)
list_limit 500 LARSASUB_STANDALONE_LIST_LIMIT Cap on the client-side-filtered lists (subscribers, charge buckets): the most recent N, with a visible truncation notice
show_back true LARSASUB_STANDALONE_SHOW_BACK Show a back button in the dashboard header
back '/' LARSASUB_STANDALONE_BACK_URL Where that back button leads
theme_view null LARSASUB_STANDALONE_THEME_VIEW A Blade view included in the dashboard <head>, after the bundled larsasub.css

Put your theme or stylesheet markup in the theme_view view — @vite([...]), <link> tags, whatever your asset pipeline uses. A stylesheet that redefines the theme tokens (--background, --primary, …) on :root / .dark re-themes the dashboards. A view name that does not exist is skipped. See Theming.

larsasub:doctor warns when a dashboard is on and the published assets are missing (php artisan vendor:publish --tag=larsasub-assets).

License tier

The licence you hold, declared by you. It is the only place your licence is recorded.

'license' => [
    'tier' => env('LARSASUB_LICENSE_TIER', 'single'),
],
Tier Covers
single (default) One production application
unlimited Any number of applications, payments settling to your own Mollie account
platform Collecting payments on behalf of others — required for Mollie Connect

This is a declaration you make, not a check the package runs: nothing here is verified, signed or sent anywhere, and the package never contacts larsasub.eu. php artisan larsasub:license reads it back. Case and surrounding spaces are ignored, and an empty value reads as single.

Enabling Connect on a tier other than platform makes the package refuse to boot rather than quietly bill on a licence that does not cover it. Only the recovery commands listed under Validation and upgrades still run, so you can correct the config. See larsasub.eu/pricing.

Mollie Connect

Connect lets each tenant connect its own Mollie account through OAuth, so their subscribers pay into it. With enabled off (the default) the package is in simple mode: one global Mollie API key. See Mollie Connect for the whole feature.

'connect' => [
    'enabled' => env('LARSASUB_CONNECT_ENABLED', false),
    'tenant_relation' => env('LARSASUB_CONNECT_TENANT_RELATION', null),
    'tenant_context' => null,
    'client_id' => env('MOLLIE_CONNECT_CLIENT_ID'),
    'client_secret' => env('MOLLIE_CONNECT_CLIENT_SECRET'),
    'scopes' => ['payments.read', 'payments.write', /* … */, 'onboarding.read', 'settlements.read'],
    'platform_organization_id' => env('MOLLIE_ORGANIZATION_ID'),
    'fee' => [
        'enabled' => env('LARSASUB_CONNECT_FEE_ENABLED', false),
        'type' => env('LARSASUB_CONNECT_FEE_TYPE', 'percentage'),
        'value' => env('LARSASUB_CONNECT_FEE_VALUE', 10),
        'description' => env('LARSASUB_CONNECT_FEE_DESCRIPTION', 'Platform fee'),
    ],
    'tenant_label' => env('LARSASUB_CONNECT_TENANT_LABEL', 'organization'),
    'tenant_name_attribute' => env('LARSASUB_CONNECT_TENANT_NAME_ATTRIBUTE', 'name'),
    'tenant_owner_attribute' => env('LARSASUB_CONNECT_TENANT_OWNER_ATTRIBUTE', 'owner_id'),
    'authorize_route' => env('LARSASUB_CONNECT_AUTHORIZE_ROUTE', 'larsasub.mollie.connect.authorize'),
    'notifications' => [
        'also_notify_platform' => env('LARSASUB_CONNECT_ALSO_NOTIFY_PLATFORM', false),
    ],
    'disconnect_drain_days' => env('LARSASUB_CONNECT_DISCONNECT_DRAIN_DAYS', 30),
],
Key Default Env Description
enabled false LARSASUB_CONNECT_ENABLED Master switch. Requires the platform licence tier
tenant_relation null LARSASUB_CONNECT_TENANT_RELATION The relation on the billable model that returns its tenant. Required while Connect is on
tenant_context null — Invokable that switches your app's tenant context (multi-database tenancy)
client_id null MOLLIE_CONNECT_CLIENT_ID The platform's Mollie Connect app id (app_…)
client_secret null MOLLIE_CONNECT_CLIENT_SECRET The platform's Mollie Connect app secret
scopes see below — The OAuth scopes requested from tenants
platform_organization_id null MOLLIE_ORGANIZATION_ID The platform's own Mollie organization id
fee.enabled false LARSASUB_CONNECT_FEE_ENABLED Charge an application fee on tenant payments
fee.type 'percentage' LARSASUB_CONNECT_FEE_TYPE percentage or fixed
fee.value 10 LARSASUB_CONNECT_FEE_VALUE 10 % of the payment, or a fixed 10.00 in the payment's currency
fee.description 'Platform fee' LARSASUB_CONNECT_FEE_DESCRIPTION The fee's description at Mollie
tenant_label 'organization' LARSASUB_CONNECT_TENANT_LABEL What the dashboards call a tenant ("organization", "company", "workspace")
tenant_name_attribute 'name' LARSASUB_CONNECT_TENANT_NAME_ATTRIBUTE The tenant attribute subscriber mails show as the app name, instead of config('app.name')
tenant_owner_attribute 'owner_id' LARSASUB_CONNECT_TENANT_OWNER_ATTRIBUTE The tenant attribute holding its owner's user id
authorize_route 'larsasub.mollie.connect.authorize' LARSASUB_CONNECT_AUTHORIZE_ROUTE The route the dashboards link to for connecting (and re-authorising) Mollie; it receives the tenant
notifications → also_notify_platform false LARSASUB_CONNECT_ALSO_NOTIFY_PLATFORM Also send tenant company mails to the platform's notifications.sales
disconnect_drain_days 30 LARSASUB_CONNECT_DISCONNECT_DRAIN_DAYS The longest a requested disconnect drains

While Connect is on, boot fails when tenant_relation is empty, when client_id does not start with app_, and on a disconnect_drain_days that is not a positive whole number; it warns when client_id and client_secret are not both set. A fee.type other than percentage or fixed fails boot whether Connect is on or not.

Credentials and scopes

The default scopes are payments.read, payments.write, refunds.read, refunds.write, customers.read, customers.write, mandates.read, mandates.write, profiles.read, organizations.read, onboarding.read and settlements.read. If you override the list, keep:

A tenant connected before a scope was added re-authorises to get it, through connect.authorize_route — see Re-authorising a connected tenant.

platform_organization_id lets the package skip application fees when a connected account is the platform's own organization — Mollie does not allow charging fees on your own account. The settlement sync uses it too, to tell the platform's own payouts from a tenant's.

Tenants

Application fees

With fee.enabled on, every tenant payment carries an application fee for the platform: percentage takes value % of the payment (rounded to cents), fixed takes value in the payment's currency, never more than the payment (a fixed fee of half the payment or more is logged). A fee under €0,01 is left off. A plan's own fee type and value, when set, take precedence over this block — even with fee.enabled off. No fee is charged on the platform's own billables (dual billing) or on an account that is the platform's own organization. See Application fees.

Disconnecting

connect.disconnect_drain_days caps a requested disconnect: the account stays connected until every payment of the tenant has a final status, and is removed after this many days anyway, with what is still under way flagged for an operator. While it drains, nothing new starts for the tenant: no subscription, payment or retry. A SEPA debit settles within about five working days; the ceiling is for a claim that never resolves. The deadline is written at the request, so changing this moves no drain that runs. Drains end in the last pass of larsasub:execute-charges — see Disconnecting a tenant.

Settlements

larsasub:sync-settlements mirrors Mollie's payouts and attaches each package payment to the payout it was paid out in, so a reference on a bank statement leads to its payments.

'settlements' => [
    'sync_platform' => env('LARSASUB_SETTLEMENTS_SYNC_PLATFORM', false),
    'platform_access_token' => env('LARSASUB_SETTLEMENTS_PLATFORM_TOKEN'),
    'platform_profile_id' => env('LARSASUB_SETTLEMENTS_PLATFORM_PROFILE_ID'),
],
Key Default Env Description
sync_platform false LARSASUB_SETTLEMENTS_SYNC_PLATFORM Also sync the platform's own Mollie account
platform_access_token null LARSASUB_SETTLEMENTS_PLATFORM_TOKEN An organization access token (settlements.read, payments.read) for that pass. Env-only, never stored
platform_profile_id null LARSASUB_SETTLEMENTS_PLATFORM_PROFILE_ID The website profile (pfl_…) the platform's payments are made on

Connected accounts are synced whenever Connect is enabled (they need the settlements.read scope). The platform's own account — the only one in simple mode, and where the platform's own charges settle under dual billing — only with sync_platform on.

That pass needs platform_access_token: Mollie serves the Settlements API to organization access tokens and OAuth tokens, never to an API key. Boot warns when the switch is on and the token is empty. Mollie also requires a profile id on that pass, so set platform_profile_id with it (warned when the token is set and the profile is not).

The frequency and look-back window live under Scheduling (sync_settlements_frequency, sync_settlements_lookback_days). See Settlements.

Email theme

The colours of the package's mails. Mail clients do not support CSS variables, so these are written as inline styles and in the <style> block; the dark values apply through @media (prefers-color-scheme: dark) in the clients that support it.

'email_theme' => [
    'primary' => env('LARSASUB_EMAIL_PRIMARY', '#4077F4'),
    'primary_ink' => env('LARSASUB_EMAIL_PRIMARY_INK'),  // null = derived
    'light' => ['body_bg' => '#F8FAFC', 'card_bg' => '#FFFFFF', 'heading' => null, 'accent_text' => null, /* … */],
    'dark'  => ['body_bg' => '#0F172A', 'card_bg' => '#1E293B', 'heading' => null, 'accent_text' => null, /* … */],
],
Key Default Env Description
primary #4077F4 LARSASUB_EMAIL_PRIMARY The accent as a surface: the button background, in both modes
primary_ink null LARSASUB_EMAIL_PRIMARY_INK What is printed on the button. null derives it: white while white clears 3:1 on primary, dark ink below that

The light and dark maps (config-file only):

Token light dark Colours
body_bg #F8FAFC #0F172A The page around the card
card_bg #FFFFFF #1E293B The card
card_border #E2E8F0 #334155 The card border
text #0F172A #F1F5F9 Body text
heading null null The mail title and section headings; null = this mode's text
text_muted #64748B #94A3B8 Secondary text
accent_text null null The accent as text (wordmark, links); null = primary
table_bg #F8FAFC #0F172A Data tables
table_header #F1F5F9 #1E293B Table header cells
table_text #334155 — Table header text; in dark mode headers use text
border #E2E8F0 #334155 Table borders

The accent is three keys because it does three jobs: primary is the button surface, primary_ink is what is printed on it, and accent_text is the accent used as text — per mode, since a colour readable on the white card is often unreadable on the dark one. The role split, with the contrast figures behind it, is in Notifications.

Partial overrides are supported — only the keys you provide are replaced. Per-tenant theming is possible by implementing getEmailTheme() on the tenant model; its values are merged over these. Accepted values are hex colours (#RGB, #RRGGBB, with or without alpha), CSS colour names and rgb()/rgba()/hsl()/hsla(). Anything else is dropped — that element keeps the shipped default — and boot warns about it.

Quote hex values in .env. An unquoted # starts a comment, so LARSASUB_EMAIL_PRIMARY=#4077F4 sets an empty value. An empty value is treated as absent here, so the mail keeps the default — but not your colour.

If you published emails/layout.blade.php, primary_ink and accent_text reach your copy and not the package's — they are layout chrome, so a copy published before those keys existed does not read them and setting them does nothing. larsasub:doctor reports it; --tag=larsasub-email-layout re-publishes that one file.

Subscription rules

Package-wide rules for how subscriptions are created and changed.

'subscription' => [
    'minimum_days_before_change' => env('LARSASUB_MIN_DAYS_BEFORE_CHANGE', 0),
    'max_notice_period_months' => env('LARSASUB_MAX_NOTICE_MONTHS', 1),
    'before_first_charge' => env('LARSASUB_BEFORE_FIRST_CHARGE'),
    'free_plan_mandate' => env('LARSASUB_FREE_PLAN_MANDATE', 'activate'),
],
Key Default Env Description
minimum_days_before_change 0 LARSASUB_MIN_DAYS_BEFORE_CHANGE Days a billable must be on a subscription before changing plans; 0 = no restriction
max_notice_period_months 1 LARSASUB_MAX_NOTICE_MONTHS The longest notice period tenants may give a plan through the bundled dashboards; null disables the cap
before_first_charge null LARSASUB_BEFORE_FIRST_CHARGE Invokable that adds cost lines before the first charge is priced
free_plan_mandate 'activate' LARSASUB_FREE_PLAN_MANDATE What a free plan does about the payment mandate: activate, mandate or refuse

minimum_days_before_change — individual plans can override it through Subscription.minimum_days_before_change; a plan's own value wins when set.

max_notice_period_months caps the notice period accepted when tenants define plans through the bundled dashboards, for both the cancellation and the renewal-cancellation notice period. A cancellation inside the notice window genuinely extends the contract and adds charges, so an oversized period is a money decision, not a display value. The default of 1 follows the strictest consumer rule in the package's home market (NL BW 6:236 sub j voids a longer period after silent renewal). It is one package-wide number — it cannot vary per tenant, plan or customer type, so raising it for B2B also drops the consumer-safe default for every B2C subscriber. null (or any non-numeric value) disables the cap. Validation-only: existing plans with larger values keep working (see Compliance).

before_first_charge — cost lines on the first charge

An invokable class run right after a subscription is created and before its first charge is priced — the one moment cost lines can be put on a new subscription and still be collected by period 1. null (default) = no hook.

class AttachSelectedModules
{
    public function __invoke(BillableSubscription $subscription, ?BillableSubscription $previous = null): void
    {
        app(SubscriptionCostService::class)->reconcileLines(
            $subscription,
            $this->desiredModulesFor($subscription),
            ['module:']
        );
    }
}

Adoption contract. Configuring this replaces an existing "re-add the modules" listener on BillableSubscriptionChangedEvent: the invokable fires on change too, so running both means an overlap-guard exception (same keys) or double-billed modules (different keys). While migrating, gate the old listener on blank(config('larsasub.subscription.before_first_charge')) — the resolver treats null, '' and a whitespace-only value alike as "no hook", so a strict === null would switch your listener off for an empty env variable while nothing took its place.

free_plan_mandate — what a free plan does about the mandate

A plan with price_amount = 0 collects nothing, so its first charge is €0 and settles locally: the customer never passes a checkout, and no payment mandate is established. That is right for a plan that will never collect anything and wrong for one meant to grow cost lines later — the package cannot tell the two apart, so you choose.

Value What happens
'activate' (default) Activate without a mandate — the right choice for genuinely free plans.
'mandate' Activate and send the customer straight into the €0,01 mandate verification — unless they already hold a usable mandate or a verification is still running, in which case nothing new is started. The activation is not held back on it: a free plan gives nothing away if the verification is abandoned, and the customer can add a method later from the payment-methods dashboard. Honoured on the provider entry point only — see below.
'refuse' Reject the subscription with a BillableSubscriptionServiceException before anything is created — unless the subscriber already holds a usable mandate, the same condition 'mandate' applies, asked through the same tenant-scoped query. Unlike 'mandate' this IS honoured on every entry point that reaches BillableSubscriptionService::createSubscription(), because it is answered there.

Read the three values as one rule: is there already something to collect with? 'mandate' goes and gets one, 'refuse' declines the subscription, 'activate' shrugs. All three leave a subscriber who already holds a usable mandate alone, because the thing the key protects against — a free subscription with no way to collect cost lines later — is not the case for them. An unrecognized value is a boot failure, never a silent fallback to activate.

That is also why the two mandate-backed entry points (createSubscriptionWithMandate(), createDeferredMandateSubscription()) are never refused: both require a usable mandate by contract, and both run through the same shared BillableSubscriptionService::createSubscription() the guard lives in. If you want free plans gone altogether, retire the PLAN (active = false) rather than configuring 'refuse'.

Where 'refuse' is answered

The guard sits in BillableSubscriptionService rather than in the provider layer: one level down the free subscription is already activated and its BillableSubscriptionActivatedEvent registered for the commit, so a refusal after that would leave an active subscription and a delivered activation mail behind a customer's error page. SubscriptionCostService::quoteFirstCharge() asks the same question and reports it as ChargeQuote::$creationRefused, so a checkout screen never names an amount for a subscription the next call throws on.

Where 'mandate' is answered — and where it is not

'mandate' does not cover every entry point, and nothing tells you when it misses. It is answered in the provider layer — MollieService::createSubscription() — because the verification is a provider call, so it fires for the bundled subscribe flow and for anything going through PaymentProviderFactory::make(…)->createSubscription(). It does not fire for a host that calls BillableSubscriptionService::createSubscription() directly, nor on createDeferredMandateSubscription() (which is handed a mandate by contract) or a plan change that lands on a free plan. There is no exception, no redirect and no log line on those paths: the free subscription is simply activated without a mandate, exactly as under 'activate'. Nothing goes wrong until money is due on it — a cost line, an upgrade — and then there is nothing to collect with, which is the situation this setting exists to prevent.

Two consequences worth acting on:

This is deliberately not a boot warning: which entry point a host calls is not readable from configuration, and the only proxy available — "no bundled dashboard is registered" — is wrong for precisely the headless host who does use the provider service.

'mandate' is idempotent in both directions: it does nothing when the subscriber already holds a usable mandate (there is already something to collect with, and a second verification is a second €0,01 plus a second mandate at their bank), and a verification that is still running is resumed rather than duplicated.

'mandate' guarantees the mandate exists, not that anything is collected. A free plan carries is_charged = false, which skips cost-line valuation entirely, so cost lines on such a subscription are still not billed. See Cost lines.

When the question cannot be answered

All three values ask is there already something to collect with? in exactly one place (PaymentProviderService::billableHasUsableMandate(), tenant-scoped, never Billable::usableMandates()). Two things can make it unanswerable, and they are not the same asymmetry:

Failure 'mandate' 'refuse'
Tenant scope cannot be established (Connect mode, no resolvable tenant) starts a verification it may not need declines a signup it might have allowed
Payment provider cannot be resolved at all never reached — the case lives in the provider layer, so no verification starts and nothing is logged refuses: it is answered in BillableSubscriptionService, before anything is created

The first row is symmetric and cheap on both sides. The second is not: a broken provider registry stops free signups under 'refuse' while being invisible under 'mandate'. That is deliberate — refusing is the only thing the service layer can still do, and a host that configured 'refuse' asked for the cautious answer — but it is worth knowing before you debug "free plans suddenly refuse" without a provider error next to it. Both are still cheap next to the alternative, which is a free subscription that silently has nothing to collect with.

Dunning

What happens to money that did not arrive or came back: follow-up charges after a chargeback, what a reversed chargeback does, and the reminder series.

'dunning' => [
    'chargeback_follow_up' => env('LARSASUB_DUNNING_CHARGEBACK_FOLLOW_UP', true),
    'chargeback_follow_up_methods' => ['directdebit'],
    'chargeback_follow_up_application_fee' => env('LARSASUB_DUNNING_CHARGEBACK_FOLLOW_UP_APPLICATION_FEE', false),
    'double_paid' => env('LARSASUB_DUNNING_DOUBLE_PAID', 'signal'),
    'chargeback_reversal_check_days' => env('LARSASUB_DUNNING_CHARGEBACK_REVERSAL_CHECK_DAYS', 60),
    'reminders' => [
        'enabled' => env('LARSASUB_DUNNING_REMINDERS_ENABLED', false),
        'since' => env('LARSASUB_DUNNING_REMINDERS_SINCE'),
        'steps' => [
            ['after_days' => 3, 'action' => 'notify'],
            ['after_days' => 10, 'action' => 'notify'],
            ['after_days' => 14, 'action' => 'escalate', 'notify' => false],
        ],
    ],
],
Key Default Env Description
chargeback_follow_up true LARSASUB_DUNNING_CHARGEBACK_FOLLOW_UP A chargeback on a paid charge becomes a follow-up charge the customer can pay
chargeback_follow_up_methods ['directdebit'] — The provider method ids whose chargebacks get a follow-up
chargeback_follow_up_application_fee false LARSASUB_DUNNING_CHARGEBACK_FOLLOW_UP_APPLICATION_FEE Connect: charge the application fee again on the follow-up
double_paid 'signal' LARSASUB_DUNNING_DOUBLE_PAID A reversed chargeback whose follow-up was already paid: signal, refund or credit
chargeback_reversal_check_days 60 LARSASUB_DUNNING_CHARGEBACK_REVERSAL_CHECK_DAYS How long after a chargeback execute-charges looks for its reversal; 0 = off
reminders.enabled false LARSASUB_DUNNING_REMINDERS_ENABLED The reminder series
reminders.since null LARSASUB_DUNNING_REMINDERS_SINCE Y-m-d: the first day whose debts get a series. Required once enabled
reminders.steps 3 / 10 / 14 days — The schedule

The reminder settings in detail:

Invoicing

Automatic invoices and credit notes for paid charges, and the VAT rules that decide what gets collected. Override the InvoiceRenderer contract to plug in your own invoice system. See Invoicing and VAT.

'invoicing' => [
    'enabled' => env('LARSASUB_INVOICING_ENABLED', false),
    'vat_rate' => env('LARSASUB_INVOICE_VAT_RATE', 21.0),
    'prices_include_vat' => env('LARSASUB_PRICES_INCLUDE_VAT', true),
    'price_display' => env('LARSASUB_INVOICE_PRICE_DISPLAY', 'exclusive'),
    'country_display' => env('LARSASUB_INVOICE_COUNTRY_DISPLAY', 'name'),
    'seller' => [
        'name' => env('LARSASUB_INVOICE_SELLER_NAME'),
        'vat_number' => env('LARSASUB_INVOICE_SELLER_VAT'),
        'coc_number' => env('LARSASUB_INVOICE_SELLER_COC'),
        'address' => env('LARSASUB_INVOICE_SELLER_ADDRESS'),
        'country' => env('LARSASUB_INVOICE_SELLER_COUNTRY', 'NL'),
    ],
    'number_prefix' => env('LARSASUB_INVOICE_PREFIX', 'INV'),
    'credit_note_prefix' => env('LARSASUB_CREDIT_NOTE_PREFIX', 'CN'),
    'vat_validation' => [
        'enabled' => env('LARSASUB_VAT_VALIDATION_ENABLED', false),
        'validator' => null,              // class implementing Contracts\VatNumberValidator; null = VIES
        'recheck_after_days' => env('LARSASUB_VAT_RECHECK_DAYS', 30), // null = only on change
        'timeout' => 10,                  // seconds, per call
    ],
],

Off by default, deliberately. Invoices are numbered, legally relevant documents; turning this on is a conscious decision that comes with configuring the VAT rate and the seller details. A fresh install must never silently issue Dutch-VAT invoices with an empty seller. Boot warns while invoicing is on and seller.vat_number is empty.

Key Default Env Description
enabled false LARSASUB_INVOICING_ENABLED Generate invoices for paid charges; also registers the invoice dashboards

VAT and prices

Key Default Env Description
vat_rate 21.0 LARSASUB_INVOICE_VAT_RATE The VAT rate before country rules apply, unless a plan or subscription carries its own vat_rate. Read when each charge is composed, so a change reaches running subscriptions from their next charge
prices_include_vat true LARSASUB_PRICES_INCLUDE_VAT Whether plan prices include VAT — this changes what is collected
price_display 'exclusive' LARSASUB_INVOICE_PRICE_DISPLAY PDF presentation: exclusive (lines ex VAT, then subtotal, VAT, total) or inclusive (lines incl. VAT, total first, then "of which VAT" per rate)
country_display 'name' LARSASUB_INVOICE_COUNTRY_DISPLAY How countries print on invoices and credit notes: name (Netherlands, Nederland) or code (NL)

A change of vat_rate reaches every subscription without a rate of its own or on its plan, from its next charge on. On exclusive prices for a customer who pays VAT, and for a zero-rated customer on inclusive ones, that moves the amount collected, and nobody is told: the package cannot observe a configuration change. A change of a plan's own vat_rate does mail the subscribers it affects (notifications.user.subscription_vat_rate_changed).

prices_include_vat is the single most consequential key here, because it changes what gets collected, not just what gets printed:

Either way the invoice totals exactly what was settled. A single payment is the exception to the "listed price" reading: its amount is always what gets collected and is never grossed up. Read Invoicing and VAT before switching this on a live installation.

price_display and country_display are presentation only: stored invoice data is always net plus VAT, and the stored country code stays the source of truth for the VAT rules. An unknown country code always prints as the code; override the wording through larsasub::countries.{CODE}. Boot fails on any other value for either key. See Country codes and names and PDF price display.

Seller and numbering

Key Default Env Description
seller.name null LARSASUB_INVOICE_SELLER_NAME Seller name on the invoice; null = config('app.name')
seller.vat_number null LARSASUB_INVOICE_SELLER_VAT The seller's VAT number — also the requester of a VIES check in simple mode
seller.coc_number null LARSASUB_INVOICE_SELLER_COC Chamber of commerce number
seller.address null LARSASUB_INVOICE_SELLER_ADDRESS Seller address
seller.country 'NL' LARSASUB_INVOICE_SELLER_COUNTRY The seller's country code — the basis of the VAT rules
number_prefix 'INV' LARSASUB_INVOICE_PREFIX Invoice numbers: INV-000001
credit_note_prefix 'CN' LARSASUB_CREDIT_NOTE_PREFIX Credit note numbers: CN-000001

In Connect mode a tenant model that implements getInvoiceSellerDetails() supplies its own seller details per tenant. Numbers are sequential, never reset, and zero-padded to six digits; under Connect they carry the tenant's reference code in front ({TENANT}-INV-000001), also after the tenant disconnected. See Numbering.

VAT number validation

Key Default Env Description
vat_validation.enabled false LARSASUB_VAT_VALIDATION_ENABLED Check business VAT numbers in VIES
vat_validation.validator null — A class implementing Contracts\VatNumberValidator; null = the bundled VIES client
vat_validation.recheck_after_days 30 LARSASUB_VAT_RECHECK_DAYS Days after which a valid number is checked again; null = only when it changes
vat_validation.timeout 10 — Seconds one VIES call may take

Opt-in; off, nothing is called. On, every business profile with an EU number is checked after each change of type, country or number (a queued job, after the save commits — never inside it) and again every recheck_after_days by larsasub:check-vat-numbers (scheduling.check_vat_numbers_frequency, with scheduling.check_vat_numbers_delay_ms between calls and scheduling.check_vat_numbers_limit per run — see Command settings).

The outcome is stored, shown on the profile dashboard, and decides the reverse charge: on, only a number VIES confirmed is reverse charged, and a business customer in another member state whose number is not confirmed is charged VAT and reclaims it.

Check the numbers you already have before switching it on. Run php artisan larsasub:check-vat-numbers --unchecked first — it works while the check is off — or existing reverse-charged customers are charged VAT until their number is checked.

The requester is the seller's own VAT number — seller.vat_number, or the tenant's getInvoiceSellerDetails()['vat_number'] under Connect. Without one VIES still answers, but issues no consultation number.

Boot refuses a validator that is not a class implementing Contracts\VatNumberValidator, and a recheck_after_days that is neither null nor a positive whole number. It warns when the check is on while invoicing is off, when seller.vat_number is empty in simple mode, and on a timeout that is not a positive number (it reads as 10) or is not below the check job's own 60-second timeout. See Validating VAT numbers.

Reference codes

The prefixes of the human-readable reference codes on charges and payments.

'reference' => [
    'first_payment_prefix' => 'FP',
    'recurring_charge_prefix' => 'RC',
    'single_payment_prefix' => 'SP',
    'mandate_setup_prefix' => 'MS',
],
Key Default Used for
first_payment_prefix 'FP' A subscription's first charge
recurring_charge_prefix 'RC' Later subscription charges, standalone charges and chargeback follow-ups
single_payment_prefix 'SP' One-time payments
mandate_setup_prefix 'MS' The €0,01 mandate verification

Codes are formatted {PREFIX}-{8 chars}, e.g. FP-A7K3MX2P — or {TENANT}-{PREFIX}-{8 chars} in Connect mode, where the tenant's connected account carries a reference code. None of these keys has an environment variable.

Logging

The log channel LarsaSub writes its own messages to — including the boot warnings.

'log_channel' => env('LARSASUB_LOG_CHANNEL', 'larsasub'),
Key Default Env Description
log_channel 'larsasub' LARSASUB_LOG_CHANNEL The channel name; null or empty = your app's default channel

The default larsasub channel — a daily log at storage/logs/larsasub.log, kept 14 days — is registered by the service provider unless config/logging.php already defines one; define it there for custom rotation or drivers. Set this to null or your app's default channel name to merge with the app log.

Table prefix

'table_prefix' => env('LARSASUB_TABLE_PREFIX', 'larsasub_'),
Key Default Env Description
table_prefix 'larsasub_' LARSASUB_TABLE_PREFIX Prefix of every LarsaSub table

Change it before running the migrations: the package does not rename existing tables.

Webhooks

'webhooks' => [
    'rate_limit' => env('LARSASUB_WEBHOOK_RATE_LIMIT', '300,1'),
],
Key Default Env Description
rate_limit '300,1' LARSASUB_WEBHOOK_RATE_LIMIT A Laravel throttle string (attempts,minutes), keyed by IP, on the public webhook routes. null or empty disables throttling

The throttle is the only brake on junk traffic against those unauthenticated endpoints, and every junk hit costs an outbound Mollie lookup. The default is deliberately generous: Mollie legitimately bursts from a small set of IPs on bulk settlement days, and a throttled-away webhook is a missed payment status until the provider retries.

Ngrok

'ngrok' => env('LARSASUB_NGROK_DOMAIN', ''),
Key Default Env Description
ngrok '' LARSASUB_NGROK_DOMAIN A public URL prefix for webhook and OAuth callback URLs during development

Development only: the value is used only while APP_ENV is local or development, and only for the provider's webhook URLs and the Mollie Connect OAuth callback URL, so Mollie can reach your machine. Otherwise those URLs are built from config('app.url'). A schemeless value gets https:// prepended.