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

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

Notifications

LarsaSub sends 49 notifications: 27 user notifications to the subscriber, and 22 company notifications to whoever runs the shop — your sales inbox, or the tenant's admins in Connect mode. Every one of them can be switched off individually. This page lists what is sent to whom and how to turn it off, then how the mails look and how they are delivered.

At a glance

User notifications Company notifications
How many 27, over 26 toggles 22
Sent to The billable's owner — see Recipients notifications.sales; in Connect mode the tenant's admins — see Company mail
Toggles notifications.user.*, in the config file only notifications.company.*, each also readable from a LARSASUB_NOTIFY_COMPANY_* variable
Off by default payment_paid, subscription_started subscription_started, recurring_charge_paid, dunning_reminder, credit_added, credit_expiring, credit_expired
Channels mail mail; database on all but two
Language The billable's stored locale The recipient's own

Every notification is queued, and goes out only after the transaction that caused it commits. Several are sent only by a scheduled command — those never go out with scheduling.enabled = false or without a running scheduler; the tables below say which.

User notifications

These go to the subscriber. Every class lives in Notifications\{Group}\User\ — for example Notifications\BillableSubscription\User\SubscriptionActivatedNotification.

Key (notifications.user.*) Notification Sent when Default
subscription_activated SubscriptionActivatedNotification A subscription activates: a paid first payment, a free plan, or a deferred-mandate creation. Never for a plan change. The activation mail on
subscription_started SubscriptionStartedNotification A subscription activated with a start date ahead reaches it (BillableSubscriptionStartedEvent — a trial ending, a deferred start). Says the date came and what is charged from now on; claims no collection. Skipped when the subscription no longer runs by then. Scheduler-only and at most once, like the event off
subscription_cancelled SubscriptionCancelledNotification The subscription was cancelled — also an upcoming one, before its start on
subscription_ended SubscriptionEndedNotification Its end date passed (a fixed term without renewal, or a cancelled subscription) — scheduler-only — or an operator terminated it with terminateSubscription() on
subscription_renewal_announced SubscriptionRenewalAnnouncedNotification Two weeks before the notice-free cancellation deadline of a fixed term that renews automatically. Scheduler-only on
subscription_renewed SubscriptionRenewedNotification Automatic renewal turned the fixed term into a continuous subscription. Scheduler-only on
subscription_changed SubscriptionChangedNotification The plan changed on
subscription_cost_changed SubscriptionCostChangedNotification Cost lines changed the amount — one mail per act, listing every change, not one per line. Not sent for a subscription's opening set, for a line imported with billedThrough, or for changes made silently on
subscription_vat_rate_changed SubscriptionVatRateChangedNotification The plan's VAT rate changed and with it this subscriber's debit (exclusive prices for a customer who pays VAT, or a zero-rated customer on inclusive prices); running and upcoming paid subscriptions without a rate of their own and with a charge still ahead. No lead time: the next charge uses the new rate. A change of invoicing.vat_rate sends nothing on
payment_paid PaymentPaidNotification A collection succeeded — never the first payment, never a mandate verification. Why it is off off
payment_failed PaymentFailedNotification A collection after the first payment failed on
payment_canceled PaymentCanceledNotification A payment after the first was canceled on
payment_expired PaymentExpiredNotification A payment link after the first payment expired on
first_payment_failed FirstPaymentFailedNotification The first payment failed, was canceled or expired on
chargeback ChargebackNotification A chargeback was received — not for one already reversed by the time the mail is sent. The chargeback mail on
refund RefundNotification A refund on a charge completed on
dunning_reminder Dunning\User\DunningReminderNotification A step of the reminder series reminds the customer of an unpaid charge. Scheduler-only. The reminder mail on
charge_announced SubscriptionChargeAnnouncedNotification, StandaloneChargeAnnouncedNotification A charge flagged notify is scheduling.notify_charges_days_before days away. Scheduler-only. Per charge, and opt-in on
credit_added CreditAddedNotification Credit was added on
credit_expiring CreditExpiringNotification Credit expires within scheduling.notify_credit_expiration_days_before days. Scheduler-only on
credit_expired CreditExpiredNotification Credit expired. Scheduler-only on
single_payment_paid SinglePayment\User\PaidNotification A single payment settled on
single_payment_failed SinglePayment\User\FailedNotification A single payment failed on
payment_method_added PaymentMethodAddedNotification A mandate became usable (MandateMarkedUsableEvent). Payment-method mails on
payment_method_removed PaymentMethodRemovedNotification A mandate was removed through MandateService::removeMandate() (MandateRemovedEvent) on
payment_method_unusable PaymentMethodUnusableNotification A mandate became unusable at the provider (MandateMarkedUnusableEvent) — never as part of a removal on

The user toggles are configured in the config file only — not through environment variables. A key missing from a published config reads its default, so payment_paid and subscription_started stay off on an older config too.

The activation mail

It has two subjects. user.subscription-activated.subject ("Your subscription is active") is sent when the start date has arrived; .subject-upcoming ("Your subscription starts on :date") when it is still ahead of today at the moment the mail renders — whatever the activation source. Title and intro follow (.title-upcoming, .intro-upcoming, and .intro-paid-upcoming for a paid activation with a future start). Override both subjects if you override one.

It is source-driven. Only the paid path claims a processed payment, so a free plan or a deferred-mandate activation does not tell the subscriber they were charged. That is what activation_source is for — see Subscriptions.

It states the billing-period total, not just the plan price: when the subscription carries cost lines, each is listed with its amount and a total row follows (plus a total during an introduction period, if there is one). Those rows are resolved when the mail renders, not when it is dispatched — which is what makes them right for the usual shape, where a host attaches cost lines in a listener on the activation event.

Recurring amounts: the total row is the debit

Nine mails quote a recurring amount: six to the subscriber (activation, started, renewal announcement, renewed, plan changed, VAT rate changed) and three company copies (activation, started, plan changed). They share Support\MonthlyAmountSummary and one Blade partial, so they cannot drift apart. Headless hosts sending their own copy call MonthlyAmountSummary::for($billableSubscription).

The amounts above the total are the listed prices — the plan price and each cost line as configured. The total is what the charge will actually collect, and VAT moves the two apart in both directions:

Install Customer Listed Collected
prices_include_vat = false pays VAT €30,00 €36,30
prices_include_vat = true zero-rated (reverse charge, outside the EU) €30,00 €24,79

So the total is labelled, and the row appears even when the subscription has no cost lines at all — that is exactly when a mail would otherwise state a net price and then debit gross:

prices_include_vat Customer Label
false pays VAT "Total per month (incl. 21% VAT)"
true EU B2B reverse charge "Total per month (VAT reverse-charged)"
true outside the EU "Total per month (0% VAT)"

Those labels are shown as they render on a monthly plan. The period is a placeholder (:per_period), so a quarterly or yearly plan reads "Total per quarter" / "Total per year" — the total is per billing period, never per month.

The combinations outside that table get no label and no extra row: with exclusive prices a zero-rated customer is charged the listed amount as-is, with inclusive prices a domestic customer is too, and a plan whose effective rate is 0% has no rate to name. Reverse charge is deliberately not "no VAT" — the customer owes it in their own country, which is what the invoice for this subscription prints. Amounts are not rewritten: ex-VAT lines under a gross total is the B2B presentation the invoice PDF already uses.

One mail is the exception: the VAT-rate notice (subscription_vat_rate_changed) prints the total row in every combination, because the new amount is what it is about.

Under the gross notice the label alone is not enough — the reader cannot re-derive the jump from the ex-VAT rows to the inclusive total. So there (and only there) the partial prints the two steps in between: "Subtotal per month (excl. VAT)" and "VAT (21%)", and the same pair over the introduction debit while an introduction period runs. The VAT amount is the subtraction of the two already-rounded totals, so the three rows add up exactly; under reverse-charge/0% nothing sits between the listed prices and the debit, and the single labelled total row stands.

A third thing moves the amount: an introduction period. The charge bills currentEffectivePrice(), so while one runs the catalogue-priced total is not the debit either. The mails print both — "Total during the introduction period" and the per-period total — so the subscriber sees what is collected now and what follows.

The company copies matter for the same reason, from the other side: that mail is the operator's first signal that a customer came in, and an amount that is a fraction of the debit makes every check on it worthless.

The cost-change notice (subscription_cost_changed) carries the same label, for the same reason: a CostLineChange reports the listed monthly amount while the set's monthlyTotal is VAT-converted and per billing period (the name is frozen wire format; ->intervalMonths says which period it is). There the notice is frozen into the change set alongside the total, not resolved when the mail renders — those are different moments (the set rides a queued job), and a customer editing their country in between would otherwise get a label describing a number it does not describe.

If you publish these mail views

The nine mails render the rows through one partial, emails/partials/monthly-amount-rows.blade.php. If you publish your own copy of one of those Blades, carry the include over — with its second argument, which names the translation namespace the partial builds its keys from:

@include('LarsaSub::emails.partials.monthly-amount-rows', ['keyPrefix' => 'user.subscription-activated'])

Without it your override keeps stating the plan price alone; without the keyPrefix argument it throws on render — see UPGRADE. The suffix arrives ready-made precisely so a template cannot build a translation key that does not exist.

The partial receives $costLines, $periodTotal, $introPeriodTotal, $vatNotice (the key suffix), $vatRate, the gross-only subtotal/VAT split $netPeriodTotal / $vatAmount / $introNetPeriodTotal / $introVatAmount (null outside a gross notice — the summary decides, the Blade only null-checks) and the two ready-made captions $perPeriod / $perMonth, plus the keyPrefix argument. The cost-change notice carries the same rows inline rather than through the partial, under its own key (new-period-total), because its total comes frozen out of the CostLineChangeSet instead of being resolved at render.

The totals are per billing period, not per month. $perPeriod is the caption that says which ("per month" on a monthly plan, "per year" on a yearly one). Cost-line rows keep $perMonth: a line's amount stays monthly whatever the plan's interval is. A mail view published before 0.19.0 still reads $monthlyTotal and then silently drops the total row (the guard is @if(($periodTotal ?? null) !== null)). Republish, or rename the variables in your copy — see UPGRADE.

The payment receipt, and why it is off

payment_paid is the receipt for a collection that simply worked. Every other key above reports a problem or a change the subscriber has to act on; this one fires on the normal case, so on a continuous monthly subscription it is a mail every month, forever. Whether that reads as service or as noise is a decision about your own tone — a B2B host billing companies usually wants it (their customer files it), a consumer app billing individuals usually does not — so the package does not make it for you.

'notifications' => ['user' => ['payment_paid' => true]],

Turning it on is also what gives notifications.attachments a recurring mail to land on. With 'invoice' set and this key off, the only subscriber mail carrying an invoice is the activation: the host gets month one's document and nothing after it.

Two cases are excluded whatever the key says:

A collection covered entirely by credit does send it: the period was billed and an invoice is generated for it. The mail then names the credit beside the €0,00 it debited — a receipt stating only "€0,00" reads as though nothing happened, which is the opposite of what a receipt is for.

The receipt states the period it collected, not only the date money moved. The two can differ, for instance on a charge collected after a missed scheduler night; the dates come from RecurringCharge::servicePeriod(), which is also where the invoice takes its line periods, so a receipt and the invoice it names cannot state different periods. A charge that covers no service period — a standalone charge — simply has no such row. The upcoming-collection notice states the same period, from the same method: it is the pre-notification, so which period is about to be collected is the thing it announces.

Neither mail repeats the plan name under "Description". The package's own description for a subscription charge is the plan name — the row those mails already open with — so it is shown only when it says something else, which is the case when your own code put text on the charge.

The charge announcement is per charge, and opt-in

charge_announced only fires for charges whose notify flag is true, and the package sets that to false on every charge it generates. That is deliberate: a subscription is a series with a fixed amount, and the one-time notice at activation covers the whole series — the SEPA rulebook asks for no more, and a monthly "we are about to charge you" mail is noise most hosts do not want.

The machinery exists for the other shape: charges you create whose amount differs per collection — usage, tokens, ad-hoc costs. There each collection does need its own notice, so plan them with RecurringChargeService::createStandaloneCharge(), which sets notify for you and takes the collection date — see Charges you create yourself. larsasub:notify-charges then mails the announcement scheduling.notify_charges_days_before days before that date, and larsasub:execute-charges collects it once the date passes.

When the amount of a subscription changes after activation, the notice for that is subscription_cost_changed — but not for every change: a subscription's opening set of cost lines, a line imported with billedThrough, and anything a host makes inside SubscriptionCostService::silently(), is deliberately not announced. See Cost lines for the exact rule.

The chargeback mail

The customer's chargeback mail names the bank's reason as one sentence per family of SEPA codes, never the code itself, and is not sent for a chargeback that was reversed by the time it goes out.

With dunning.chargeback_follow_up on, it is the customer's only notice of the follow-up charge: it states whether that follow-up is open (with a pay button), settled from credit, paid or in progress (with "complete payment" for an open checkout). Switch it off only if you notify the customer yourself — ConfigValidator warns when follow-ups are on and this mail is off.

The reminder mail

dunning_reminder is one mail per reminding step of the reminder series (DunningReminderDueEvent): a failed collection or a chargeback follow-up, with the amount open and a pay button (payment_url). The last reminder says so in its subject.

Whether it goes out is decided when it renders: not once the charge is paid, a payment is under way, the series closed, or a later step was taken. To remind customers yourself, switch it off (or list \PetersDevelopment\LarsaSub\Listeners\Dunning\SendDunningReminderToUserListener::class in disabled_listeners) and listen to the event.

The payment-method mails

The three mandate mails are the confirmation banking practice expects on any change to how money gets collected, and the security signal for a hijacked session. Each names the method, the masked account and the billable the mandate belongs to — a For row, since for a host with several billables per user that is a team or an environment (:billable is also available to a translation override of the title and intro).

Company notifications

These go to your sales recipients, or in Connect mode to the tenant's admins (Company mail). Every class lives in Notifications\{Group}\Company\. Each toggle can also be set from the environment variable in the last column — see Environment variables.

Key (notifications.company.*) Notification Sent when Default Environment variable
subscription_activated BillableSubscription\Company\SubscriptionActivatedNotification A subscription activates — as the user mail on LARSASUB_NOTIFY_COMPANY_SUBSCRIPTION_ACTIVATED
subscription_started BillableSubscription\Company\SubscriptionStartedNotification The upcoming→active moment (a trial ending) — as the user mail, and off for the same reason off LARSASUB_NOTIFY_COMPANY_SUBSCRIPTION_STARTED
subscription_cancelled BillableSubscriptionCancelledNotification A subscription was cancelled on LARSASUB_NOTIFY_COMPANY_SUBSCRIPTION_CANCELLED
subscription_changed BillableSubscription\Company\SubscriptionChangedNotification The plan changed on LARSASUB_NOTIFY_COMPANY_SUBSCRIPTION_CHANGED
recurring_charge_paid RecurringChargePaidNotification A collection succeeded — never the first payment (the activation mail covers it) or a mandate verification. One mail per customer per billing period: a reporting stream rather than a notification off LARSASUB_NOTIFY_COMPANY_CHARGE_PAID
payment_failed RecurringChargeFailedNotification A collection after the first payment failed (a failed first payment mails only the subscriber) on LARSASUB_NOTIFY_COMPANY_PAYMENT_FAILED
recurring_charge_canceled RecurringChargeCanceledNotification A payment after the first was canceled on LARSASUB_NOTIFY_COMPANY_CHARGE_CANCELED
recurring_charge_expired RecurringChargeExpiredNotification A payment after the first expired on LARSASUB_NOTIFY_COMPANY_CHARGE_EXPIRED
chargeback RecurringChargeChargebackNotification A chargeback on a charge. Shows the bank's reason code and, with follow-ups on, the follow-up and its state on LARSASUB_NOTIFY_COMPANY_CHARGEBACK
refund RecurringChargeRefundNotification A refund on a charge completed on LARSASUB_NOTIFY_COMPANY_REFUND
dunning_reminder Dunning\Company\DunningReminderNotification A copy of every reminder step. States the debt and the step reached, not whether the customer was mailed. Off because the sales inbox already got the failed or chargeback mail for the same debt off LARSASUB_NOTIFY_COMPANY_DUNNING_REMINDER
credit_added Credit\Company\CreditAddedNotification Credit was added off LARSASUB_NOTIFY_COMPANY_CREDIT_ADDED
credit_expiring Credit\Company\CreditExpiringNotification Credit expires soon. Scheduler-only off LARSASUB_NOTIFY_COMPANY_CREDIT_EXPIRING
credit_expired Credit\Company\CreditExpiredNotification Credit expired. Scheduler-only off LARSASUB_NOTIFY_COMPANY_CREDIT_EXPIRED
single_payment_paid SinglePayment\Company\PaidNotification A single payment settled on LARSASUB_NOTIFY_COMPANY_SINGLE_PAYMENT_PAID
single_payment_failed SinglePayment\Company\FailedNotification A single payment failed on LARSASUB_NOTIFY_COMPANY_SINGLE_PAYMENT_FAILED
single_payment_chargeback SinglePayment\Company\ChargebackNotification A chargeback on a single payment on LARSASUB_NOTIFY_COMPANY_SINGLE_PAYMENT_CHARGEBACK
single_payment_refund SinglePayment\Company\RefundNotification A refund on a single payment completed on LARSASUB_NOTIFY_COMPANY_SINGLE_PAYMENT_REFUND
payment_needs_attention ProviderPayment\Company\NeedsAttentionNotification A charge or single payment was parked for an operator. Alarm on LARSASUB_NOTIFY_COMPANY_PAYMENT_NEEDS_ATTENTION
connect_connection_lost Connect\Company\MollieConnectionLostNotification A tenant's OAuth connection died. Connect only. Alarm on LARSASUB_NOTIFY_COMPANY_CONNECT_CONNECTION_LOST
connect_disconnected_open_payments Connect\Company\MollieDisconnectedOpenPaymentsNotification A Mollie connection was removed with payments still under way — at once, at a drain's deadline, or by a revocation in Mollie. One mail per removal; those payments are flagged without a mail each. Connect only. Alarm on LARSASUB_NOTIFY_COMPANY_CONNECT_DISCONNECTED_OPEN_PAYMENTS
mandate_registration_failed Mandate\Company\MandateRegistrationFailedNotification A first payment was paid and registering the mandate behind it failed. Alarm on LARSASUB_NOTIFY_COMPANY_MANDATE_REGISTRATION_FAILED

In Connect mode these go to the tenant's own admins; the platform sales recipients get a copy when connect.notifications.also_notify_platform is on, or when the tenant side could not be reached. Those are two different administrations, so the admin button resolves per audience — see The admin button knows who is reading.

Operational alarms

Four of these are alarms rather than sales news. Leave them on.

mandate_registration_failed arrives next to the generic payment_needs_attention mail for the same charge, deliberately: that one says a payment needs looking at, this one names a customer whose next collection is already lost. Want only one? Turn the generic one off — nothing repairs this state on its own, so a customer in it has to be found. A mandate step that fails while the payment is still in flight (a card under review, a PayPal authorization, a bank transfer) does not produce this mail — nothing has been collected there, and the generic one covers it.

It has two shapes, and the copy follows the charge. The €0,01 mandate verification runs through the same webhook route as a subscription's first payment, so a paid verification whose registration fails arrives here too — with no subscription, and with a cent collected for a payment method rather than for a plan. Subject, title, intro and action branch on that. Here the customer was charged and still has no payment method.

A redelivered webhook sends it again. Mollie retries, the mandate step is attempted again, and a second failure is a second mail for the same paid charge — deliberately not deduplicated, since each attempt is a fresh statement about a state that has not repaired itself. Treat the charge id in the mail as the key when you file these.

Mandate verifications send no mail

A €0,01 mandate-setup checkout that fails, expires or is canceled mails nobody. It fires MandateSetupFailedEvent instead of the charge failed/expired/canceled events, so neither the subscriber nor the company funnel hears about an abandoned verification — where nothing was wrong and nothing needs doing. Listen to that event to roll back an onboarding attempt (events). The one company mail a verification can still produce is mandate_registration_failed, for a paid one.

Wording

The company activation mail has the same two subjects as the subscriber's: company.subscription-activated.subject ("New subscription activated") when the start date has arrived, .subject-upcoming ("New subscription confirmed, starts on :date") while it is ahead, with .title-upcoming and .intro-paid-upcoming alongside — so an operator is never told a trial has been activated before anything was collected.

The four company.single-payment-* mails say "Payment received", "Payment failed", "Chargeback received" and "Refund processed" — not "single payment": the distinction between a SinglePayment and a RecurringCharge is the package's, and the reference and description in the body are what the reader needs.

Turning one off

Flip the config key:

'notifications' => [
    'company' => [
        'refund' => false,
    ],
],

A toggle that is not a boolean is read the way filter_var() reads one: "1", "true", "yes" and "on" are on, anything else — null included — is off. ConfigValidator and larsasub:doctor warn about every non-boolean toggle, so an env() without a value does not silence a mail unnoticed.

For finer control — replacing one default listener with your own rather than silencing the notification — disable the listener:

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

Then register your own listener on BillableSubscriptionActivatedEvent. Only the notification listeners (the Send*Listener classes) can be listed there; any other class is a boot warning. See Events.

The toggle is the line between "we mail your customer" and "you do"

notifications.user.* is not a mute button; it is the handover. With a key on, the package owns that mail to your customer — copy, theming, tenant branding, locale, the delivery log. With it off, the package sends nothing and the event is yours: nobody is mailed unless you mail them.

There is no half-way state, and that is deliberate — two confirmations of one payment is worse than either one alone. So a host that sends its own customer mail turns the matching key off and keeps its own; that combination is supported and is what disabled_listeners above refines when you want to replace the listener rather than silence the notification.

Before switching one off to add a document, check Attachments — that is the reason hosts most often rebuild a mail they were otherwise happy with.

Recipients

User notifications go to the people behind the billable; company notifications go to whoever runs the shop. Both are resolved in the queue worker, inside the tenant context, when the mail is sent.

Subscriber mail

User notifications go to whoever getNotifiableUsers() returns on the billable owner. Traits\IsBillableModel ships a default: the owner itself when it uses Laravel's Notifiable (a User, at its own address), and otherwise the billable's own address — billable_email, which every BillableModel declares — as a route-based recipient, with billable_name on the To header.

So a Team, Company or Tenant owner gets its subscription and payment mail at its own inbox without any host code; when each company is its own owner model (indirect billing), a user's three companies each get their mail at their own address. In explicit mode the owner of every registration is the user, so the default mails the user — per-company routing there is the hook's job, below. An owner whose billable_email is empty or not an address gets nothing, with a warning per send.

Override it to send to specific people instead, for instance every billing contact of a team:

public function getNotifiableUsers(): Collection
{
    return $this->members()->where('billing_contact', true)->get();
}

Routing one mail type elsewhere

When one mail type belongs to someone else, implement Contracts\ResolvesNotificationRecipients on the owner instead. Its one method receives the notification about to be sent, so it can route by class — the subscription mails that carry an invoice to the bookkeeper, everything else to the owner — and it wins over getNotifiableUsers() whenever the owner implements the contract. It applies to user notifications only. A plain address works as a recipient through the Notification facade's route():

use Illuminate\Database\Eloquent\Model;
use Illuminate\Notifications\Notification;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\Notification as Notify;
use PetersDevelopment\LarsaSub\Contracts\BillableModel;
use PetersDevelopment\LarsaSub\Contracts\ResolvesNotificationRecipients;
use PetersDevelopment\LarsaSub\Notifications\BillableSubscription\User\SubscriptionActivatedNotification;
use PetersDevelopment\LarsaSub\Notifications\RecurringCharge\User\PaymentPaidNotification;
use PetersDevelopment\LarsaSub\Traits\IsBillableModel;

class Tenant extends Model implements BillableModel, ResolvesNotificationRecipients
{
    use IsBillableModel;

    // …

    public function getNotifiableUsersFor(Notification $notification): Collection
    {
        $carriesInvoice = $notification instanceof SubscriptionActivatedNotification
            || $notification instanceof PaymentPaidNotification;

        if ($carriesInvoice && filled($this->invoice_email)) {
            return collect([Notify::route('mail', $this->invoice_email)]);
        }

        return $this->getNotifiableUsers();
    }
}

Which classes to name depends on what you switched on. With notifications.attachments = 'invoice' the first period's invoice rides on the activation mail; every later period's rides on the payment receipt, and that one (notifications.user.payment_paid) ships off. Routing PaymentPaidNotification alone therefore sends the bookkeeper nothing on a stock install, and never the first invoice.

Other mails hang an invoice on too, and the example leaves them with the owner on purpose: a single payment's SinglePayment\User\PaidNotification carries its own invoice, and the refund and chargeback mails (RecurringCharge\User\RefundNotification, ChargebackNotification) attach the invoice they reverse — the chargeback mail only while no follow-up charge exists: once it asks for the money again, the voided invoice is not attached. Add whichever your bookkeeper should see.

Explicit billable mode. One owner has several registrations — one per company. The hook then reads the company off the notification, not off $this: every package notification extends BaseLarsaSubNotification and $notification->deliveryBillable() is the registration the mail is about.

The simplest shape needs no instanceof at all: return the same address for every notification, and all subscription and payment mail of that billable lands there — a tenant's own inbox instead of the user who owns it. In indirect billing that is already the default; in explicit mode the hook is what makes it per company, by reading the address off $notification->deliveryBillable() rather than off $this.

An empty collection from the hook means nobody: nothing is sent and, as for an owner without recipients, a warning is logged. To suppress a mail type for everyone, use its toggle rather than the hook.

The hook is picked up by the contract, never by name: an owner with a getNotifiableUsersFor() of its own that does not implement ResolvesNotificationRecipients is left alone, and PHP enforces the signature of one that does. It is called through the Billable registration inside the worker's tenant context, so an owner in a per-tenant database resolves correctly — see Multi-database tenancy.

Plain addresses as recipients

A route-based recipient (Notification::route('mail', …)) — the default for an owner without Notifiable, or one your hook returns — differs from a model in three ways:

Company mail

In simple mode company notifications go to notifications.sales (LARSASUB_NOTIFICATIONS_SALES):

Form Example
Role name 'sales' — requires spatie/laravel-permission
Email list 'admin@example.com,sales@example.com'
Single email 'admin@example.com'
Disabled an empty string, or null

An address that belongs to a user account (auth.providers.users.model, matched case-insensitively) is mailed as that user, with their own locale preference; any other address — a shared sales@ mailbox — as a plain address. With the key empty nothing reaches a platform inbox — in simple mode that is every company notification — and ConfigValidator warns at boot.

A plain address takes only mail (Plain addresses as recipients). When the channel list a company notification reads — the default, or an override for a company class — holds no channel an address can take, such as ['database'], every company mail to such an address is dropped. larsasub:doctor names those addresses and the lists.

In Connect mode the package looks for the tenant's admins first, in this order:

  1. A platform billable (in dual billing, the platform charging a tenant) is the platform's own sale: its company mail goes to notifications.sales, never to that tenant's admins.
  2. getTenantNotifiableUsers() on the billable owner — override it on your owner model to name the tenant's billing admins. The trait's default returns nobody.
  3. The tenant owner: the tenant through connect.tenant_relation, its owner through connect.tenant_owner_attribute (the matching relation, or a lookup on auth.providers.users.model). That user must use Notifiable.

The platform sales recipients get a copy as well when connect.notifications.also_notify_platform = true, and get the mail instead when no tenant recipient could be reached. The two Connect alarms (connect_connection_lost, connect_disconnected_open_payments) are about the tenant itself rather than a billable: they go to the tenant owner (step 3), with the same platform copy and fallback. See Mollie Connect.

Channels

Every notification goes out on the channels in notification_channels:

'notification_channels' => [
    'default' => ConfigHelpers::parseNotificationChannels(env('LARSASUB_NOTIFICATION_CHANNELS', 'mail')),
    'overrides' => [
        \PetersDevelopment\LarsaSub\Notifications\RecurringCharge\Company\RecurringChargeFailedNotification::class => ['mail', 'database'],
    ],
],

LARSASUB_NOTIFICATION_CHANNELS takes a comma-separated list (database,mail); overrides sets the list per notification class. Every LarsaSub notification uses Notifications\ConfigurableNotificationChannels, which is what reads this config.

A channel is used only when the notification implements it. Each value is checked against the notification's to…() methods, and a channel it lacks is skipped for that notification, with a warning in the log:

Channel Implemented by
mail Every notification
database (needs Laravel's notifications table) The company notifications, except CreditExpiringNotification and CreditExpiredNotification. No user notification implements it
slack, broadcast, other channels None — skipped for every package notification

A channel class name is accepted beside the alias when its basename names one of those methods — Laravel's own MailChannel and DatabaseChannel, for instance.

Per-tenant databases: a channel that persists through its own model — Nova's, for one — writes to config('database.default'), which inside the activated tenant context is the tenant's database. Laravel's own database channel is fine (it writes through the notifiable's relation). See Channels with their own model.

Attachments

Off by default: most of these mails are signals, and a signal does not need a document. notifications.attachments turns that around for the cases where it does — typically the paid invoice as a PDF.

'notifications' => [
    'attachments' => 'invoice',          // false | 'invoice' | InvokableClass::class
],

It is readable from LARSASUB_NOTIFICATIONS_ATTACHMENTS, so the two literal forms can differ per environment.

false (default) attaches nothing.

'invoice' attaches the invoice LarsaSub generated for the record the mail is about. It is silent when there is nothing to attach — invoicing is per charge while this setting is global, so most mails legitimately have no invoice — and it needs barryvdh/laravel-dompdf. If you bound your own InvoiceRenderer, the document is not the package's to produce: it declines and logs, and you want the invokable form.

For a recurring subscription, turn on notifications.user.payment_paid as well. That is the mail each period's invoice rides on. Without it the only subscriber mail carrying one is the activation, so 'invoice' delivers month one's document and nothing after it.

It attaches the invoice on every mail that has one, the refund and chargeback mails included — those hang off the same charge, so what rides along is the original invoice, not the credit note that voided it. One exception: the customer chargeback mail attaches nothing once a follow-up charge exists, because it asks for the money again. That is deliberate: a credit note can be partial and there can be several, so which document belongs on which mail is a bookkeeping decision the package will not guess. Want the credit note instead? Use the invokable form and read $record->invoice->creditNote.

An invokable class name hands the decision to you:

use Illuminate\Database\Eloquent\Model;
use Illuminate\Mail\Mailables\Attachment;
use PetersDevelopment\LarsaSub\Models\SinglePayment;
use PetersDevelopment\LarsaSub\Notifications\SinglePayment\User\PaidNotification;

class BillingAttachments
{
    /** @return Attachment[] */
    public function __invoke(string $notification, ?Model $record): array
    {
        if ($notification !== PaidNotification::class || ! $record instanceof SinglePayment) {
            return [];
        }

        return [
            Attachment::fromData(fn () => $this->pdfFor($record), "invoice-{$record->reference}.pdf")
                ->withMime('application/pdf'),
        ];
    }
}

You get the notification's class name and the record, not the notification instance: the class names are public API and the properties on those classes are not. $record is the payment or charge the mail reports, and is null for mails that have none — return [] for everything you have no file for. On the chargeback mails it is the charged-back original, also when a follow-up exists (its credit note is often the document that fits there).

This runs in the queue worker, inside toMail(). Anything thrown is caught and logged and the mail goes out without the attachment, because the alternative is no mail at all: sendEach() catches per channel, so an escaping throwable costs the recipient their notification entirely. Do not let a missing file throw — return []. A non-Attachment entry in the returned array is skipped and logged; the valid ones beside it still go.

Action-button URLs

Four mails to the SUBSCRIBER carry a configurable action button over two keys, and all fall back to a bundled dashboard:

Key Mail Fallback
notifications.payment_url Payment failed The payments dashboard
notifications.payment_url Chargeback, while its follow-up charge is payable or has an open checkout — the invokable receives the follow-up (isChargebackFollowUp() is true), not the charged-back original The payments dashboard
notifications.payment_url The dunning reminder — the invokable receives the debt: a failed charge (a standalone one has no subscription) or a chargeback follow-up The payments dashboard
notifications.first_payment_url First payment failed The subscription dashboard

None of the four falls back to a bundled dashboard while the charge's Connect tenant drains its disconnect: a payment started there would be refused, so the mail carries no button. A URL of your own is still used.

Explicit billable mode. The payments-dashboard fallback shows the billable of the logged-in user, which in explicit mode need not be the one the mail is about. Set payment_url there — ConfigValidator warns once the reminder series is on, which multiplies these mails.

Three accepted forms each: null (the fallback), an absolute URL with {charge} — and {subscription} for the first-payment key — or the class name of an invokable:

namespace App\Billing;

use PetersDevelopment\LarsaSub\Models\RecurringCharge;

class TenantPaymentUrl
{
    public function __invoke(RecurringCharge $charge): ?string
    {
        $tenant = $charge->billable?->resolveTenant();

        return $tenant === null ? null : "https://{$tenant->domain}/billing/pay/{$charge->id}";
    }
}

This runs in the queue worker, not in a request. Every bit of context must come from $charge. It must not read auth(), session(), request() or a container-bound "current tenant" singleton — billable.owner_resolver documents exactly the opposite pattern on purpose, and copying it here yields an empty or wrong tenant. Anything the invokable throws is caught and logged, and the mail falls back; a button is not a money path.

A class name survives config:cache; a closure does not. A template using {subscription} on a charge without a subscription is discarded (and logged) rather than rendered with a hole.

Disabling the fallback dashboard without configuring a URL leaves the payment-failed mail with only the generic app button, the chargeback mail telling the customer to pay "from your account" without a pay button, the reminder without a pay button (its text then promises none), and the first-payment-failed mail with no action button at all. ConfigValidator warns for that combination.

The subscriber's button, and where "back" is

Every user mail ends with a "go to :appName" button. By default its URL is config('app.url') — correct only when every subscriber really does live at that one address. In Connect they do not: the same mail carries the tenant's name and the tenant's colours, so a button to app.url sends a mail branded as Tenant A to the platform.

Connect is where the problem shows, not where the key applies. Nothing about it is gated on connect.enabled. A host with per-tenant hostnames and no Connect — the platform billing its tenants, each tenant on its own domain — has the same problem, and usually a worse version of it, because there app.url is the marketing site: the reader of a mail about their own subscription lands on a sales page. Set the key whenever a subscriber's environment is not config('app.url').

notifications.user_app_url answers it, in the same three forms as the keys above and under the same queue-worker contract. Its invokable receives the billable:

use PetersDevelopment\LarsaSub\Models\Billable;

class SubscriberAppUrl
{
    public function __invoke(Billable $billable): ?string
    {
        $tenant = $billable->resolveTenant();

        return $tenant === null ? null : "https://{$tenant->domain}/account";
    }
}

The billable rather than the tenant, because that is what every user mail has and the tenant follows from it. Returning null is the documented "no opinion" — a host that can place only some of its subscribers declines for the rest and keeps config('app.url'). A mail without a billable never asks.

This key substitutes no placeholders, unlike the two action URLs above. https://{tenant}.example is not a template here — the leftover brace makes it an unusable URL, so it is dropped and the button stays on config('app.url'). That is the same rule the two tenant_*_url keys follow, and for the same reason: a token could only be filled with an id, which is not what belongs in a hostname. Use the invokable form when the URL differs per subscriber. ConfigValidator warns about it at boot; a class name that does not resolve to an invokable is a boot fatal.

Three boundaries are worth stating, because each is invisible in the rendered mail:

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

The admin button knows who is reading

Every company mail also carries a "go to admin" button, and in Connect mode one such mail can reach two audiences: the tenant's own admins, and — with also_notify_platform on, or when the tenant side could not be reached — the platform's sales recipients. They administer different systems, so there is no single admin URL.

Audience Where the URL comes from
Platform (sales recipients; the only audience in simple mode) The admin-panel package (larsasub-nova, larsasub-filament) → the bundled subscription dashboard → the app root
Tenant (the tenant's own admins) notifications.tenant_admin_url → a resolver registered for that audience → the first registered of the subscribers, charges, subscriptions and connect dashboards → no button

And the per-record deep link beside it:

Audience Where the deep link comes from
Platform The admin-panel package → the bundled dashboard route for that model → the app root (never null)
Tenant notifications.tenant_model_url → a resolver registered for that audience → no deep link

The button's label follows the audience as well: the platform copy says layout.go-to-admin ("Go to admin"), the tenant copy layout.go-to-tenant-admin ("Go to :appName", with the tenant's own name) — to a tenant admin "the admin environment" is simply where they work, and the word suggests somebody else's panel. go-to-tenant-admin is a key of its own even though its default text equals go-to-app: two audiences, two override handles.

The layout reads two view variables for this:

A published layout from before these variables keeps the platform label for everyone; larsasub:doctor reports that in Connect mode when the layout still renders the admin button.

The platform chain is never used for a tenant admin, and that is the point: an admin panel only knows the operator's own pages. Mailing that path to a tenant sends them somewhere they cannot go — and on a host that scopes its root URL per tenant it arrives as the platform's path on the tenant's own domain, a URL that does not exist there at all.

notifications.tenant_admin_url takes the same three forms as the keys above — null, an absolute URL, or the class name of an invokable — with the same queue-worker contract. Its invokable receives the tenant:

use Illuminate\Database\Eloquent\Model;

class TenantAdminUrl
{
    public function __invoke(?Model $tenant): ?string
    {
        return $tenant === null ? null : "https://{$tenant->domain}/office";
    }
}

This key substitutes no placeholders: a {tenant} token could only be filled with an id, which is not what belongs in a hostname. Use the invokable form when the URL differs per tenant. A class name that does not resolve to an invokable is a boot fatal — deliberately, since the alternative is a nonsense link in someone's mail; any other unusable string is ignored with a boot warning.

The dashboard fallback resolves with route(), against app.url. If your tenants live on their own domains, set this key even when you run the bundled dashboards.

The deep link to one record

The button above answers "where does this tenant administer". The per-record button — "View subscription", "View payment" — answers "where does this tenant look at THIS record", and that is a different question: most hosts can answer the first with one URL and the second only with a computation.

notifications.tenant_model_url is that key, and it takes the model the mail is about — a SinglePayment, RecurringCharge or BillableSubscription:

use Illuminate\Database\Eloquent\Model;
use PetersDevelopment\LarsaSub\Models\SinglePayment;

class TenantRecordUrl
{
    public function __invoke(Model $model): ?string
    {
        if (! $model instanceof SinglePayment) {
            return null;          // no page for it — no deep link
        }

        return "https://{$model->billable->tenant->domain}/office/invoices/{$model->id}";
    }
}

Answer for the models you have a page for and return null for the rest; a host that only knows where its invoices live should leave the others without a deep link rather than guess. null falls through to a resolver registered for the tenant audience, and then to no deep link.

The plain-URL form is accepted but rarely what you want — every deep link then points at the same page. The same queue-worker contract applies as to the keys above: derive everything from $model, never from auth(), session() or a container-bound "current tenant". Anything thrown costs the button and is logged; the mail still goes out.

Leaving the key unset is a supported state, not a misconfiguration: a tenant admin's mail then carries the single admin button and no deep link. Nothing falls back to the platform's own record page — that is the one URL this audience exists to withhold.

AdminUrlResolver::resolveUsing(..., AdminAudience::Tenant) reaches the same slot and looks like the natural hook, but that class is @internal and is there for admin-panel packages (larsasub-nova, larsasub-filament). Hosts use the config key: it survives config:cache and is covered by the public-API promise. See PUBLIC-API.md.

When nothing resolves, the mail renders no admin button at all — not even the generic "go to app" one, since that URL is the platform's too. That is deliberate: a button that 404s costs more than a missing one.

larsasub:test-notifications sends outside the funnels, so its company samples always render the platform link. To preview what a tenant admin receives, configure notifications.tenant_admin_url and trigger the real event.

Email theming

Colors come from larsasub.email_theme, based on the package's brand theme. Light is the default; dark mode is progressive enhancement through @media (prefers-color-scheme: dark).

Application-wide:

'email_theme' => [
    'primary' => '#FF6B35',                  // the button SURFACE
    'light' => ['card_bg' => '#FAFAFA'],     // partial overrides are fine
],
Key Default Colors
email_theme.primary #4077F4 (LARSASUB_EMAIL_PRIMARY) The button surface — and more, see below
email_theme.primary_ink null: derived (LARSASUB_EMAIL_PRIMARY_INK) The button label
light.* / dark.* Slate neutrals body_bg, card_bg, card_border, text, heading, text_muted, accent_text, table_bg, table_header, border, and in light mode table_text

Per tenant — implement getEmailTheme() on the tenant model:

public function getEmailTheme(): array
{
    return [
        'primary' => $this->brand_color,
        'light'   => ['card_bg' => '#FAFAFA'],
        'dark'    => ['card_bg' => '#1A1A2E'],
    ];
}

Only the keys you provide are overridden. Resolution order: the tenant's getEmailTheme() → config('larsasub.email_theme') → hardcoded defaults. In Connect mode the mails about a tenant's subscribers also carry the tenant's name (connect.tenant_name_attribute) instead of config('app.name').

The accent is three keys, not one

Your brand color does three different jobs in these mails, and they do not want the same value:

Key Role Needs
primary the button surface nothing as a background — but see below
primary_ink what is printed on that surface contrast against primary
light.accent_text / dark.accent_text the accent as text: wordmark, links contrast against the card, which differs per mode

While accent_text is null — the default — primary is not only a background. It is what accent_text falls back to, so it also paints the wordmark (on the card) and the footer link (on the page background, just below it), and in both places it needs contrast as text. Setting primary to a light brand color and nothing else therefore gets the button right and leaves #fe9900 at 2,15:1 on the card and 2,05:1 under it. Set accent_text in the same change, as below.

Why it is split, in one table — contrast ratios against the surfaces these colors actually sit on:

Color as text on the white card as a surface under dark ink #0F172A as text on the dark card #1E293B
#fe9900 2,15:1 8,31:1 6,81:1
#a85500 5,30:1 3,37:1 2,76:1
#4077F4 (default) 4,07:1 4,39:1 3,60:1

An orange brand color is excellent as a button but unreadable as a link; its darker sibling is fine as light-mode text and fails on the dark card. So a brand like that configures all three:

'email_theme' => [
    'primary' => '#fe9900',              // the button stays the real brand color
    'light'  => ['accent_text' => '#a85500'],   // readable on white
    'dark'   => ['accent_text' => '#fe9900'],   // readable on the dark card
],

primary_ink is left out on purpose there: null means the package derives it — white while white clears 3:1 on primary, dark ink below that. On #fe9900 that gives you a dark label automatically. The derivation runs after a tenant's theme is merged, so each tenant's label follows its own primary. Set the key to decide yourself; explicit always wins.

Be clear about what the derivation is: a net against an unreadable button label, not a WCAG guarantee. The label is 15px/600, so 3:1 is below what it should strictly meet. The threshold is 3 and not 4.5 so that no install running the package default (#4077F4, 4,07:1 against white) has its buttons change without asking. If you need the guarantee, set primary_ink.

Heading color

light.heading / dark.heading color both kinds of heading in a mail: the title (.email-title) and the section heading above a detail table (.email-section-title). Deliberately one key for both — two headings in different colors inside one mail is a design mistake, and it keeps the theme at two keys instead of four. null (the default) means "use this mode's text", so nothing changes until you set it.

Customizing the mail templates

Prefer the config keys above to editing templates: they reach every mail and survive upgrades. When you do need the markup, publish the layout alone:

php artisan vendor:publish --tag=larsasub-email-layout

--tag=larsasub-views copies the whole view tree — every notification view, the dashboards and the invoice PDFs — and those copies then stay frozen at the version you published, amount rows, VAT labels and heading sizes included. Re-publishing the layout alone (with --force) after an upgrade is safe; it touches nothing else.

If you published emails/layout.blade.php, two of the three accent keys reach your copy of that file and not the package's. primary and heading are written inline by the notification views, but the button label, the wordmark and the footer link are layout chrome — so primary_ink and accent_text are read in the layout and nowhere else. A copy published before those keys existed simply does not mention them, and setting them then does nothing at all: no error, no warning, no log line. larsasub:doctor reports it, as it reports a layout that predates $userAppUrl or $adminAudience, and a published customer chargeback mail that predates chargeback follow-ups.

Three more things matter when you edit the views:

Locale

The user funnel sets the notification locale to the billable's stored locale (larsasub_billables.locale), for every recipient. When none is stored, a recipient's own HasLocalePreference applies; a recipient without one — a plain address, or a model that never declared a preference — gets the owner's billable_locale, never the queue worker's app locale.

The company funnel deliberately forces nothing at all — admins keep their own language.

Package UI strings ship in en and nl. A third content locale falls back to app.fallback_locale for mail chrome unless you publish overrides (--tag=larsasub-translations, into lang/vendor/larsasub). Laravel merges a published file over the package's, so a key your copy lacks still renders the package text. See Multilingual plan content.

How delivery works

Every LarsaSub notification is ShouldQueue, and so is every listener that sends one; both are dispatched only after the surrounding transaction commits, so a rolled-back change mails nobody.

The user and tenant funnels do not call Notification::send(). They dispatch a single delivery job that activates the tenant context (when configured), resolves the recipients inside it, and sends to each of them one channel at a time. The platform sales funnel needs no tenant context and sends through Laravel's ordinary queued Notification::send().

Consequences for hosts:

Every delivery is recorded in larsasub_notification_deliveries — one row per notification, recipient and channel — so an interrupted job does not re-mail a whole list on retry: a delivery that already happened is refused. The log stores no subject, body or e-mail address (a plain-address recipient is a keyed hash), and recording fails open: if the table breaks, mail still goes out. Rows are pruned daily by larsasub:prune-notification-deliveries after retention.notification_deliveries_days (365 by default; LARSASUB_NOTIFICATION_DELIVERIES_RETENTION_DAYS; null or 0 keeps them).

The full reasoning — and the one funnel that stays your responsibility in a multi-database setup — is in Multi-database tenancy.

Testing notifications

larsasub:test-notifications renders and sends any notification to an address of your choice, from in-memory fixtures:

# List every notification key
php artisan larsasub:test-notifications --list

# Send one
php artisan larsasub:test-notifications ChargeFailedCompany --to=test@example.com

# Send them all
php artisan larsasub:test-notifications --all --to=test@example.com

# Send one under a specific scenario
php artisan larsasub:test-notifications SubscriptionActivatedUser --scenario=intro --to=test@example.com

--to is required for sending. Most mails render the fixed scenario unless you pass another (continuous, intro, fixed and the renewal variants below). Some mails have scenarios of their own, and send all of them when you pass none:

Keys Scenarios
SubscriptionActivatedUser, SubscriptionActivatedCompany continuous, intro, no-renewal, renewal-direct, renewal-longer, renewal-same, renewal-from-none, free, deferred, paid-deferred
SubscriptionStartedUser, SubscriptionStartedCompany deferred, free, paid-deferred
SubscriptionRenewedUser renewed-with-notice, renewed-direct
DunningReminderUser, DunningReminderCompany failed, standalone, follow-up, final

The payment-failed, -canceled and -expired mails also have a …StandaloneUser key for the standalone-charge variant.

The command sends synchronously, without a queue worker. Its fixtures are unsaved models, so it creates no billing records and is safe against a development database. It is not gated by scheduling.enabled.