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
- Turning one off
- Recipients
- Channels
- Attachments
- Action-button URLs
- Email theming
- Locale
- How delivery works
- Testing notifications
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.
$perPeriodis 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$monthlyTotaland 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:
- The first payment. The activation mail already reports that period and carries the same invoice, so the receipt would be the second mail about one collection.
- A mandate-setup verification. €0,01 that comes straight back, and deliberately never invoiced — "we have received your payment" is the wrong sentence for it.
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).
- Added fires when a mandate becomes usable. An import through
recordExistingMollieMandate()sends it too; step 2 of Adopting existing mandates says how to keep it from your customers. Re-adding a method the customer already has resolves to the existing record and stays silent (payment methods). - Removed fires for a removal through
MandateService::removeMandate(). When the removal moved running agreements over, the mail names the method and masked account future collections run on. - Unusable fires on a genuine provider-side invalidation: the weekly
validation finding the mandate revoked at the bank, a 404 during a status
refresh, a connect-reset. It is proactive on purpose — without it the
customer's first signal is the failed collection weeks later; with it they get
the window before the next charge to set up a new method. The invalidation
inside every
removeMandate()carriespartOfRemovaland is skipped, so a self-service removal is announced exactly once, by the removed mail.
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.
payment_needs_attentionfires when a charge could not be executed at all (no usable mandate, tenant context unavailable, a provider rejection) — and on a paid chargeback follow-up whose double payment the package could not, or was told not to, give back (dunning.double_paid, which defaults tosignal). Those charges sit parked until somebody looks.connect_connection_lostfires when a tenant's OAuth connection died. Without it, charges park while the dashboard still says "connected".connect_disconnected_open_paymentsfires once when a connection is removed while payments were still under way — their money may still reach the tenant's Mollie balance, but their outcome can no longer be read here. Each of them is flagged without a mail of its own, so this mail is the only one about them (Disconnecting a tenant).mandate_registration_failedfires when a first payment was paid and registering the mandate behind it failed — money in, subscription active, invoice raised, and nothing to collect next period with. The typed hook for host-side handling isMandateRegistrationFailedEvent(events).
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:
- Channels. It has no notifications table and no key, so the
databasechannel — alias or Laravel's class — is dropped for it, on every send path, and the other configured channels are sent. If that leaves nothing, the mail is not sent:ConfigValidatorwarns when an owner mailed at its own address meets a channel list (default or per-class override) that holds only model-bound channels. Only Laravel's exact classes are dropped: a channel class of your own that extends the database channel is kept, and fails for such a recipient. - Language. It has no preference of its own, so it gets the billable's
stored content locale like every user mail, and where none is stored the
owner's
billable_locale. - Delivery log. It is recorded as a keyed hash of the address, never the address, and a retried job recognises it by that hash.
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:
- 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. getTenantNotifiableUsers()on the billable owner — override it on your owner model to name the tenant's billing admins. The trait's default returns nobody.- The tenant owner: the tenant through
connect.tenant_relation, its owner throughconnect.tenant_owner_attribute(the matching relation, or a lookup onauth.providers.users.model). That user must useNotifiable.
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 owndatabasechannel 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_paidas 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-Attachmententry 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 | 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_urlthere —ConfigValidatorwarns 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 readauth(),session(),request()or a container-bound "current tenant" singleton —billable.owner_resolverdocuments 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:
- An action button keeps its own URL.
payment_urlis "where do I pay THIS",user_app_urlis "where do I live". The payment-failed mail carries both, pointing at different places, and that is correct. - Company mails ignore the key. Half that audience is the platform's sales
inbox, for which
app.urlis the right answer; the other half istenant_admin_url's business. The key's name is the rule. - The footer line does not move. "This email was sent by
<platform>" namesconfig('app.name'), not the tenant, so it is an attribution rather than a way back. Pointing it elsewhere would print one party and link to another.
If you published
emails/layout.blade.php, the button there reads$appUrland this key arrives as a new$userAppUrlvariable — so setting it does nothing at all until you port that one line. Nothing errors and nothing logs;larsasub:doctorreports it, andUPGRADE.mdholds 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:
$adminAudience—'platform'or'tenant'; those two strings are contract, see PUBLIC-API.$adminEnvironmentName— the name of the environment the button leads into. For the tenant audience that is the tenant's name, which is not always$appName: the connection-lost alert brands itself as the platform. It isnullwhen the tenant has no name, and the layout then keeps the platform label rather than printing the platform's name over a link into the tenant's environment.
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@internaland is there for admin-panel packages (larsasub-nova, larsasub-filament). Hosts use the config key: it survivesconfig:cacheand 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-notificationssends outside the funnels, so its company samples always render the platform link. To preview what a tenant admin receives, configurenotifications.tenant_admin_urland 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:
- Theme variables come as view data. Variables from the layout's
@phpblock are not available inside@section/@yieldcontent, and an@includecannot share variables back to the parent scope. That is why the theme variables ($tableBg,$tableHeader,$tableText,$border,$textColor,$headingColor,$accentText,$primary,$primaryInk) are passed as view data fromNotifications\HasEmailViewData::getEmailViewData()rather than resolved in the layout alone. - Colors are set inline, on purpose, because a
<style>block does not survive every mail client. That is also why the config keys are the supported route and a CSS override is not: your<style>rule loses to the package's inline value unless you add!important, and it loses entirely in the clients that strip<style>. - Keep the three levels of prominence: the title (
.email-title) above a section heading (.email-section-title) above the table header. A section heading should neither outrank the title nor be outranked by the table below it.
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:
viaQueues(),viaConnections(),withDelay()and notification-level job middleware have no effect on the delivery job.- The job tries three times, 30 seconds apart, and only a failure of your
connect.tenant_contexthook makes it retry — nothing has been sent at that point. A tenant that no longer resolves stops the job quietly, and a failing recipient is logged without costing the others their mail. - A job attempt is capped at 60 seconds. Keep the queue's
retry_afterabove that (Laravel's default is 90), or a second worker can pick up the same job while the first is still sending. - With
queue.default = synceverything runs inline, inside the request or command that caused it;larsasub:doctorwarns about that outside local/testing.
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.