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
- Database connection
- Billable model
- Payment provider
- Currency
- Content locales
- Terms acceptance
- Redirect routes
- Features
- Data retention
- Scheduling
- Policies
- Notifications
- Notification channels
- Standalone dashboards
- License tier
- Mollie Connect
- Settlements
- Email theme
- Subscription rules
- Dunning
- Invoicing
- Reference codes
- Logging
- Table prefix
- Webhooks
- Ngrok
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.phpwith 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 asnull— which for some keys switches a feature off (a missingretention.notification_deliveries_daysdisables pruning).larsasub:doctorlists every key the shipped file has and yours lacks. Re-publish withphp 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
authmiddleware 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 usuallynullon 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 nullwhen you cannot), or put it on the destination page, behind your ownauth.
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 mistypedcleanup_unused_billable_subscriptions_frequencyrunstwiceDaily, a mistypedvalidate_mandates_frequencyrunsweekly.
| 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, andlarsasub:end-billable-subscriptionsis not limited: if execution lags past a subscription'send_date(a fixed term without renewal, or a cancellation), that subscription is deactivated first and its last charge is then parked withneeds_attentioninstead 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:doctorprints it as aReconciliation windowline — threshold plus interval, with the threshold as it will be applied (clamped) rather than as configured — andConfigValidatorlogs a warning while it is 60 or more. On the shipped defaults that warning fires (30 +daily= 1470): either scheduleexecute-chargesateveryFifteenMinutesor 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 whenscheduling.auto_registeris 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:
- An entry in
actionswins:true,false,'admin','owner_or_admin','owner'or'authenticated'. Any other value denies, with a log line. - Otherwise
admin_only => truemakes it'admin'. - Otherwise
ownership_required => truemakesviewAnyandview'owner_or_admin'. - 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_resolverdocuments 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 surviveconfig: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
salesempty 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$userAppUrlvariable — so setting it does nothing until you port that one line. Nothing errors and nothing logs;larsasub:doctoris 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 | |
|---|---|---|
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:
invoicesandtenant_invoicesare only registered whileinvoicing.enabledis on.tenant_connectandtenant_feesare only registered in Connect mode.
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:
onboarding.read, orMollieConnectService::getOnboardingStatus()fails with a 403 and stays null;refunds.readandrefunds.write— withoutrefunds.readno tenant refund ever completes in the package, not even one made in the Mollie dashboard; withoutrefunds.writenone can be requested (both warned at boot and bylarsasub:doctor);settlements.read, or no tenant's payouts can be synced (warned at boot).
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
connect.tenant_owner_attributemust name a real attribute on your tenant model. The ownership check compares it with the signed-in user's key — a wrong name means the attribute is never set and nobody can manage the tenant.connect.tenant_name_attributeis read as a string (aStringableis cast) and trimmed. Anything else — an int, an array, an empty value, or an accessor that throws (logged) — leaves the mail onconfig('app.name')and its admin button on the platform label.connect.tenant_contextis called as__invoke(?\Illuminate\Database\Eloquent\Model $tenant): void. It receives the tenant stored on a registration, ornullbefore a step for a registration without one (the platform billable, a simple-mode registration) — so its parameter must be nullable (?Model, or your own?Tenant); a hook that cannot takenullis a boot failure. Onnull, undo what the tenant branch does, without network I/O. A non-platform registration without a tenant while Connect is on getsnulland then the tenant its owner'stenant_relationnames — or, when it names none, the step is refused. Registrations only store a tenant in Connect mode (or when you pass it explicitly), so a hook set whileconnect.enabledis false only ever receivesnullfor the ordinary ones; LarsaSub warns at boot. Leave itnullwhen all billable models live on one database. See Multi-database tenancy.
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, soLARSASUB_EMAIL_PRIMARY=#4077F4sets 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:']
);
}
}
-
$previousis filled on the plan-change path only. It is both the discriminator (subscribe vs. change) and the data a re-adding host needs — the old subscription is already wound down by the time the hook runs, so querying backwards for it is not a contract. Gate on$previous === nullto hook subscribes only. -
Fires on all three first-charge entry points: subscribe, subscribe with an existing mandate, and the plan change. It deliberately does not fire on
createDeferredMandateSubscription(), where the documented contract is that the caller adds its own lines after creation. -
Code that calls the provider entry points itself can pass a
?Closure $beforeFirstChargeinstead; an explicit closure always wins. This config key exists for hosts on the bundled dashboards, which have no call site to pass one through. -
On the bundled subscription dashboard, implement
Contracts\ProspectiveCostLinesas well. The subscribe card names the amount the click collects, and the lines this hook adds are part of it — but the hook runs inside the creating transaction, so nothing can simulate it.prospectiveLines($plan, $owner)states them beforehand (the shapeaddLine()takes); let__invoke()add exactly those on a new subscription, so there is one source:class AttachSelectedModules implements ProspectiveCostLines { public function prospectiveLines(Subscription $plan, Model $owner): array { return $this->modulesFor($owner, $plan); // [['key' => 'module:x', 'description' => '…', 'amount' => 4.95], …] } public function __invoke(BillableSubscription $subscription, ?BillableSubscription $previous = null): void { if ($previous !== null) { return; // the plan change: your own rule } $costs = app(SubscriptionCostService::class); foreach ($this->prospectiveLines($subscription->subscription, $subscription->billable->owner) as $line) { $costs->addLine($subscription, $line['key'], $line['description'], $line['amount'], $line['starts_on'] ?? null, billedThrough: $line['billed_through'] ?? null); } } }Without it the dashboard shows no subscribe button for any plan and refuses the subscribe, rather than name an amount that leaves the modules out; the boot log and
larsasub:doctorsay so. A headless host passes the lines toquoteFirstCharge()itself and does not need the contract. -
The package wraps the call in
SubscriptionCostService::batch(), so one hook is one act — no half-finished totals are mailed to the subscriber, and the opening-composition rule keeps the whole set silent. -
It runs inside the creating database transaction: keep it cheap and DB-only, no external calls. A throw rolls the entire creation back (no subscription, no charge, no lines, no activation event) and the subscriber sees a generic translated message — never the host exception's own text.
-
ConfigValidatorandlarsasub:doctorreject a class that does not exist, is not invokable, or whose__invoke()provably cannot take these arguments — at boot, because the alternative is a TypeError inside every subscriber's creating transaction. Declared, single, named types are judged; untyped parameters andmixed/objectare not. A blank value (null,'', whitespace) means "off" and is never fatal; a value that is not a string is.
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 onblank(config('larsasub.subscription.before_first_charge'))— the resolver treats null,''and a whitespace-only value alike as "no hook", so a strict=== nullwould 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:
- Headless hosts: subscribe through the provider service, not through
BillableSubscriptionService, if you rely on this setting. The provider method delegates to the service anyway — it is the same subscription, plus the redirect this key is about. - Or start the verification yourself where you create free subscriptions:
PaymentProviderFactory::make(config('larsasub.provider'))->createMandateSetupPayment($owner, $method), which carries the same in-flight guard.
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 carriesis_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 |
chargeback_follow_up— a chargeback on a paid charge becomes a follow-up charge for the same period and VAT, payable by the customer through the manual payment flow and never collected from the mandate. On by default;LARSASUB_DUNNING_CHARGEBACK_FOLLOW_UP=falseswitches it off. Never retroactive: a chargeback delivered while it was off keeps no follow-up. See Recurring charges.chargeback_follow_up_methods— the provider method ids (PaymentMethod::provider_id) whose chargebacks get one. SEPA only by default: a SEPA storno is final, while a card chargeback is a dispute the merchant may still win; addcreditcardonly if you accept asking for payment while it runs. PayPal disputes run outside Mollie and are never seen. See Payment methods.[]means no method. Not a list of strings is a boot failure; an id that is no payment method here at all (a typo), andpaypal, arelarsasub:doctorwarnings, checked oncechargeback_follow_upis on.chargeback_follow_up_application_fee— Connect: charge the application fee again on the follow-up. Off: Mollie keeps the original's fee on a chargeback, so a second fee charges the tenant twice for one period. Turn it on only if the platform gives its fee back on a chargeback itself. See Connect.double_paid— the bank reversed a chargeback after its follow-up was paid, so the customer paid the period twice:signal(default; flag the follow-upneeds_attention— the company mail, while that notification is on — and you give it back),refund(at Mollie from a queued listener with retries, normally on the original payment — which Mollie very likely refuses once it was charged back, and then it is flagged as withsignal) orcredit(wallet credit the refund ceiling counts).refundneeds a real queue; onsyncit runs inline in the webhook without retries. Anything else is a boot failure. See Recurring charges.chargeback_reversal_check_days— Mollie sends no webhook when a chargeback is reversed, solarsasub:execute-chargesasks: it fetches the original payment of every follow-up whose chargeback is younger than this many days and not yet seen reversed, paid follow-ups included (default 60 — a card dispute is usually decided within that).0switches the daily look off; starting a payment on a follow-up asks Mollie first either way. Null or empty is the default; anything that is not a whole number of days is a warning and reads as the default. A config published before this key needs nothing: the default applies.reminders— the reminder series (Reminder series): after a collection failed or expired (or a retry the customer started was canceled at the checkout), or after a follow-up was created, the customer is reminded on the days instepsuntil the charge is paid.escalatetells your application through an event; the package never cuts off access by itself. It runs daily throughlarsasub:process-dunning(scheduling.process_dunning_frequency), so it needs the scheduler (boot warns whilescheduling.enabledis off).
The reminder settings in detail:
- A config published before the reminder series existed has no
remindersblock, and only missing top-level keys are filled in: copy the block, or the env variable has nothing to switch on (Switching it on). since(Y-m-d) is the first day whose debts get a series — a failed collection by its planned date, a follow-up by the day it was created — so switching the series on for an install that ran for months does not escalate every old debt on day one. It is required onceenabledis on; an empty or unparsable value is then a boot failure, a date in the future a warning.stepsneeds strictly increasing positive wholeafter_days(counted from the failure, or from the chargeback mail) and anactionofnotifyorescalate. It takes no other keys than those andnotify(true or false — optional, and neverfalseon anotifystep, which is the reminder itself;'notify' => trueon anescalatestep sends a last reminder with the event). Anything else is a boot failure even while the series is off, because each case keeps the schedule it opened with — changing it only affects new cases. An empty schedule while the series is on is a warning.
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:
true—price_amountincludes VAT (B2C). Zero-rated customers (EU B2B reverse charge, outside the EU) are charged the net amount.false—price_amountexcludes VAT (B2B), and VAT is added before collection: a 14,90 plan debits 18,03 domestically. Zero-rated customers pay the listed amount, and a customer without aninvoice_countryis not grossed up either — the package never adds VAT on a guess; the invoice then splits the collected amount instead.
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 --uncheckedfirst — 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.