Documentation for larsasub v0.38.2.
On an older version? Your release notes ship with the package (CHANGELOG.md).
Multi-database tenancy
What LarsaSub needs from you when the billable owner models — or IsPayable
entities — live in a per-tenant database: a connect.tenant_context hook,
and a few rules for code of your own. Apps with one database configure nothing
here and can skip this page.
- What stops being true
- Tenant-scoped registrations
- The tenant_context hook
- Leaving the tenant context
- Where it is invoked
- Failure handling
- The notification queue
- Delivery records and duplicates
- What stays yours
- The registration without a tenant
What stops being true
Three assumptions break in that setup:
- Owner ids are no longer globally unique. Customer #1 exists in every tenant database.
Billable->owneris only resolvable while your app's tenant context is active — the right connection, and your "current tenant" container binding. Web requests get that from your own middleware. Webhooks, scheduled commands and queue workers do not.Cache::no longer means one place. If your default store is tenant-scoped — a per-tenant database cache, a prefix that varies with the tenant, or a failover store whose fallback differs depending on whether a tenant connection is reachable — then the same facade call reads different storage depending on which context it runs in. Two requests that are meant to land in different contexts cannot hand anything to each other through it.
The third one is why the Mollie Connect authorization flow keeps its state in a table rather than the cache: that flow crosses origins by design, and the callback arrives with no tenant bound. It is handled — nothing to configure — but the reasoning is written out under The OAuth flow, because it applies to your own code too (see What stays yours, below).
Tenant-scoped registrations
larsasub_billables stores tenant_type/tenant_id plus a non-null tenant_key
that is part of the unique owner key, so customer #1 of tenant A and customer #1
of tenant B get distinct registrations.
Billable::resolveTenant() prefers the tenant stored on the registration.
That read never touches the owner model, which breaks the chicken-and-egg problem
of needing the owner to find the tenant whose database the owner lives in.
Pass the tenant explicitly whenever you are outside request context. Without
it, the tenant is derived from the owner's connect.tenant_relation, which is
only reliable inside a tenant-scoped request:
use PetersDevelopment\LarsaSub\Helpers\BillableResolver;
BillableResolver::getBillable($owner, $tenant); // read: null when there is none yet
BillableResolver::getOrCreateBillable($owner, $tenant); // write: creates the registration lazily
The tenant_context hook
An invokable class that switches your app's tenant context. Given a tenant, it
points the tenant connection at the right database, purges it and rebinds your
current-tenant singleton. Given null, it undoes exactly that — see
Leaving the tenant context.
namespace App\Tenancy;
use Illuminate\Database\Eloquent\Model;
class LarsaSubTenantContext
{
public function __invoke(?Model $tenant): void
{
if ($tenant === null) {
// LarsaSub is about to work for the platform, or outside any
// tenant. Undo everything the branch below does — and nothing
// else: never purge or repoint the connection LarsaSub itself
// writes on, and do no network I/O here.
if (! app()->bound('currentTenant')) {
return; // already left — LarsaSub calls this on every platform step
}
config(['database.connections.tenant.database' => null]);
\DB::purge('tenant');
app()->forgetInstance('currentTenant');
\URL::forceRootUrl(config('app.url'));
return;
}
config(['database.connections.tenant.database' => $tenant->database]);
\DB::purge('tenant');
app()->instance('currentTenant', $tenant);
\URL::forceRootUrl($tenant->url);
}
}
The parameter must accept null — ?Model, or your own nullable tenant class
(?Tenant); an untyped parameter works too. A hook that cannot be called with
a tenant or with null — declared Model $tenant, or with a required second
parameter — is a boot fatal and a larsasub:doctor error. Upgrading a hook
written for a release before 0.29: see UPGRADE.md.
// config/larsasub.php
'connect' => [
'tenant_context' => \App\Tenancy\LarsaSubTenantContext::class,
],
The hook runs on every call — nothing is cached, in both directions: null
after null still calls it. Your own code can switch context between two
LarsaSub operations (long-running workers, Octane), so LarsaSub never assumes the
context it set is still the current one. Keep the hook cheap and idempotent — the
short-circuit in the null branch above is what keeps a run of thousands of
platform charges cheap.
Leaving the tenant context
When null arrives. Before every step LarsaSub runs for a registration that
carries no tenant — the platform billable in dual billing, a simple-mode
registration — and before the platform pass of the settlement sync. It also
arrives at the end of the Connect disconnect pass, and before a company
notification whose tenant cannot be established goes to the platform
recipients instead. Without it, a platform step that follows a tenant step in
the same loop, job, worker or Octane process ran in that tenant's context: a
synchronous listener of yours, an owner lookup, an IsPayable entity would
read the previous tenant's database.
Only where LarsaSub is the one setting the context — the entry points listed
below. What LarsaSub does inside a web request of yours
on its own — an invoice download, a plan change that settles a charge locally —
never sends null: your context stays. Two things do, wherever they run:
- a pass or platform step you call yourself establishes its own context per
step, from a request too:
SettlementService::syncPlatform(), andsyncTenant()for a connected account that is the platform's own organization; thePaymentReconciliationServicepasses andrecoverMandateFor();BillablePruneService::pruneOrphaned();DunningService::process();VatNumberValidationService::checkDue();MollieConnectService::finalizeDisconnects(). Call them from a command or a job when you have a hook; - a LarsaSub notification on the
syncqueue connection runs inside the request that dispatched it, and the hook receivesnullthere for a platform registration (just as it receives the tenant there for a tenant one). That leaves your tenant for the rest of the request: in dual billing, a tenant admin who changes the tenant's own platform subscription on the tenant host continues without a tenant once the notification has run. With atenant_contexthook configured, run a real queue connection — thesyncdriver switches your context in the middle of your request in both directions.
Undo everything the tenant branch did. Connection, container binding, root
URL — and anything else your tenant bootstrapping sets: a permission cache, a
tenancy package's own bootstrapper teardown. If you repoint config('database.default')
per tenant, point it back. What you must not do is purge or repoint the
connection LarsaSub itself writes on (larsasub.database.connection, or
config('database.default') when that is null), and you must not do network I/O: the
prune calls the hook inside a LarsaSub transaction that holds a row lock.
Throwing on null is the same transient failure as throwing on a tenant —
logged, and handled per path, fail-closed:
| Path | A hook that throws on null |
|---|---|
| Charge-execution loops | The platform charge is parked with needs_attention |
| Planning loop | The platform subscription is skipped this run; the next run plans it |
| Webhook processing | Throws — the webhook answers 500 and Mollie retries |
| Reconciliation / mandate recovery | Parked or deferred / skipped, as for a tenant |
| User notification funnel | The job fails and retries; after three attempts the notification is lost |
| Company notification funnel | The job fails and retries; after three attempts the platform sales copy goes out |
| Prune | The platform billable is skipped as tenant-context-unavailable |
| Settlement sync | The platform pass is skipped with outcome tenant_context |
A registration without a tenant in Connect mode is resolved through its
owner's tenant_relation, and refused only when that names none. See
The registration without a tenant.
Where it is invoked
Entry points — paths without a request context of their own, where LarsaSub
establishes the context: the stored tenant, or null to leave.
- The charge-execution loops and the planning loop (
larsasub:execute-charges,larsasub:plan-charges) PaymentReconciliationService— the reconciliation pass, mandate recovery and the chargeback-reversal check- Mollie webhook processing —
processFirstPayment(),processRecurringCharge(),processSinglePayment() - The notification job's user and company funnels
larsasub:generate-missing-invoices- The settlement sync — each tenant account, the platform pass, and a connected account that is the platform's own organization
- The prune — the platform billable's owner check
- The reminder series (
larsasub:process-dunning) — each case, each charge it opens a series for, and each event it sends again, so its events reach your synchronous listeners in that registration's context - The VIES check (
invoicing.vat_validation) — the queued job after a profile change and each registration oflarsasub:check-vat-numbers/VatNumberValidationService::checkDue().checkNow()does not: you call it in your own request, where the context is yours - The queued refund of a doubly paid chargeback follow-up
(
dunning.double_paid = refund) - The Connect disconnect pass (
MollieConnectService::finalizeDisconnects(), the last pass oflarsasub:execute-charges) — each draining account's tenant, and it leaves the tenant context when it is done
Nested calls — reached from an entry point or from your own request, so they
only ever activate a tenant and never send null:
SinglePayment::processEntities()— theIsPayablehooksInvoiceService::createForCharge()andcreateForSinglePayment()RefundService::refund()- The tenant-admins notification funnel (its anchor is the tenant)
Failure handling
What LarsaSub does when the context cannot be established: your hook throws
(usually transient), or the tenant stored on the registration no longer
resolves (permanent). A failure is always logged. What happens next is
path-dependent, and deliberately so — some paths skip, others raise
TenantContextException so the work is retried:
| Path | On failure |
|---|---|
| Notifications | User funnel: skip (tenant gone) or retry (hook threw); company funnel: the platform copy — at once (tenant gone) or once the retries run out (hook threw) |
IsPayable entities |
Skipped, logged |
| Webhook processing | Throws — the provider retries later |
| Charge-execution loops | Skip the charge with needs_attention |
| Planning loop | Skip the subscription this run (hook threw), or leave it unplanned until the registration is repaired (tenant gone) — reported by larsasub:plan-charges; repair it within the billing period, or that period is not billed |
| Reconciliation | The claim is deferred to the next run, and parked with needs_attention once it has failed longer than scheduling.reconcile_park_after_hours; mandate recovery and the chargeback-reversal check skip the charge this run |
| Settlement sync | The account is skipped this run with outcome tenant_context |
| Prune | Skipped as tenant-context-unavailable (hook threw); with a hook configured, a stored tenant that is gone is the orphan the prune exists for |
RefundService::refund() |
Refused before anything is sent: RefundServiceException with RefundBlocker::ProviderAccountUnresolvable |
| Reminder series | Skip the item this run (hook threw) or until the registration is repaired (tenant gone); never flags the charge |
| VIES check | Job: released with backoff (hook threw) or stopped (tenant gone); sweep: the registration is skipped this run. Nothing is called, nothing written |
| Double-paid follow-up refund | Retried (hook threw); flagged for an operator when the tenant is gone or the retries run out |
| Connect disconnect pass | The draining account is skipped until the next run |
| Invoice generation | Throws; larsasub:generate-missing-invoices counts the charge as an error, goes on with the next one and exits non-zero |
Money paths abort because an invoice is an immutable snapshot: it must never
snapshot the wrong tenant's data. The same table holds for a hook that throws on
null — see Leaving the tenant context.
The notification queue
Every LarsaSub notification is ShouldQueue, so activating the context where one
is dispatched would buy nothing on its own.
Notification::send() hands the work to a second job, SendQueuedNotifications,
where the recipient is re-hydrated from a ModelIdentifier and toMail() reads
the billable's owner again — for the recipient name, the app name and the email
theme. Nothing would throw: the connection name survives serialization, so
in the worker it points at whichever tenant database that connection currently
targets. The result would be another tenant's data, possibly mailed.
Job middleware cannot repair that either: SerializesModels restores models in
__unserialize(), which runs before the middleware pipeline and before
handle().
So LarsaSub does not use Notification::send() for its own funnels. All three —
the user funnel, the company funnel, and the tenant-admins funnel that has no
billable — dispatch one job that carries only what is safe to serialize (the
Billable registration, or the tenant itself), calls your tenant_context
hook in handle() (with the tenant, or with null for a registration without
one), and only then resolves the recipients and sends to each of
them inside that context, in the same job. There is no second hop for a recipient
to get lost in.
Two consequences to know about:
-
A transient hook failure is retried, a permanent one is not. If your hook throws — a database that refused a connection, a lookup that timed out — the job fails and the queue makes up to three attempts in total. Nothing has been sent at that point, so a retry cannot duplicate a mail. If the tenant stored on the registration no longer resolves at all, the notification is skipped and logged instead: retrying cannot bring it back, and sending without context could reach another tenant's users. For the two company funnels, the platform sales copy is sent by the job's failure hook once the retries are exhausted, so an alert about a failed payment still reaches someone — exactly once.
-
A LarsaSub notification on the
databasechannel is covered. Its rows are written from inside the activated context like everything else the job does, so they land in the tenant database the notification belongs to. The mechanism is worth knowing, because it does not generalize: Laravel'sDatabaseChannelwrites through the notifiable's ownnotifications()relation, andDatabaseNotificationdeclares no connection of its own — so the row follows whatever connection the recipient was loaded on. Both common tenancy shapes land right: a named tenant connection propagates from the recipient, and a host that repoints Laravel's default connection per tenant is inside that switch here anyway.A channel that writes through its OWN model is not covered. Nova's notification channel is the one to watch: it persists with
Notification::create()onLaravel\Nova\Notifications\Notification, which is not reached through the notifiable and therefore followsconfig('database.default')— inside the activated context that is the tenant database, whilenova_notificationsis landlord data. The failure is silent by design:NotificationDelivery::sendEach()isolates each channel, so the mail still goes out and the operator sees only a log line (Table 'tenant_x.nova_notifications' doesn't exist). Any channel with a fixed table of its own behaves the same. If you use one, pin its model to the connection that table lives on — LarsaSub cannot know where that is.One more edge that predates all of this: the platform sales funnel resolves configured addresses without a user account to an
AnonymousNotifiable, which has nodatabaseroute at all. Those recipients are mail-only, andlarsasub:doctornames them when the channel list a company notification reads (notification_channels.default, or an override for its class) holds no channel they can take.
Delivery records and duplicates
DeliverNotification sends to a whole recipient list inside one job, and a worker
killed mid-list (a --timeout SIGALRM, or an OOM kill) runs neither a catch nor
failed(). The retry would otherwise restart at the top and re-mail everyone
already served.
larsasub_notification_deliveries closes that: one row per (notification uuid,
channel), claimed when the send starts and confirmed when it completes, with a
unique index that doubles as the race guard between workers.
The bound this buys is one duplicate per interrupted attempt — the recipient whose send was cut off after the provider accepted it but before the confirmation — not one overall. A job interrupted twice can duplicate twice. What it removes is the re-mailing of the entire list.
Two design choices worth knowing:
- A row left in the claimed state is retried, not skipped — but only once its lease has expired. An unproven delivery could be a missed payment reminder, and that outweighs a duplicate.
- The listeners fail open. A broken log table must never become a mail outage — which also means a database failure disables deduplication rather than blocking sends.
Nothing sensitive is stored: no subject, no body, no content hash, no email
address. Billable::anonymize() deletes the rows, and
larsasub:prune-notification-deliveries prunes them after
retention.notification_deliveries_days (default 365). See
Compliance.
What stays yours
Everything you queue yourself. Your own listeners on LarsaSub events, and your
own notifications, cross the boundary on their own. If your app has per-tenant
databases, make your queue tenant-aware anyway — tenancy packages
(spatie/laravel-multitenancy, stancl/tenancy) ship this as a feature.
Anything of your own that two contexts have to share. The cache trap above is
not specific to billing: a one-time token you mint on the main domain and redeem
on a tenant host, a hand-off between a landlord route and a tenant route, a
signed link from an email — all of them read Cache:: from two different
contexts, and a tenant-scoped default store quietly gives them two different
backends. The failure looks like an expired link, which is the worst possible
disguise for a configuration fault. Put that kind of state somewhere both sides
provably reach: a landlord table, or an explicitly named store
(Cache::store('redis')) rather than the default.
One package funnel is deliberately out of scope: NotifyRole (the platform sales
funnel) resolves its recipients from config and keeps using the ordinary queued
send. That is safe for the shipped shape — plain addresses and a central-database
role lookup — but it is not a guarantee the package can make for every host. If
your notifiable users are tenant-scoped, treat that funnel as yours to make
context-aware.
The registration without a tenant
larsasub_billables only stores a tenant when Connect is enabled, or when you pass
the tenant explicitly to BillableResolver::getOrCreateBillable(). A registration
without one is one of three things, and LarsaSub treats them differently:
- The platform billable (dual billing): its owner lives centrally. The entry
points call the hook with
null. - Any registration while Connect is off (simple mode): the same —
null. With a hook configured, this is the shapelarsasub:doctorand the boot warnings report: tenants are only derived automatically in Connect mode, so registrations created the ordinary way store none and the hook only ever receivesnullfor them. It is a warning rather than an error because the explicit-tenant route still works: registrations you created that way do carry a tenant, and for those the hook runs exactly as it would in Connect mode. - A non-platform registration without a tenant while Connect is on and a hook
is configured — typically one created before Connect was switched on. The
entry points leave the context first and then ask the owner's
tenant_relation, exactly as payment collection does: with owners on the central database that usually names the tenant, and the step runs in it. When it names none (the owner lives in a tenant database nobody can name), the step is refused: a charge parked, a subscription not planned (reported bylarsasub:plan-charges; repair it within the billing period, or that period is not billed), a customer notification dropped — a company one goes to the platform recipients only — a webhook answered 500 until Mollie stops retrying (about a day; nothing fetches that status again), all logged as "Registration carries no tenant and its owner names none". Store the tenant on the registration to repair it; UPGRADE.md has the query. The prune reports the same registrations asowner-unverifiable.