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).

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

Three assumptions break in that setup:

  1. Owner ids are no longer globally unique. Customer #1 exists in every tenant database.
  2. Billable->owner is 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.
  3. 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:

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.

Nested calls — reached from an entry point or from your own request, so they only ever activate a tenant and never send null:

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:

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:

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: