Documentation for larsasub v0.38.2.
On an older version? Your release notes ship with the package (CHANGELOG.md).
Billable models
This page covers how your own models become billable: the contract and traits,
the Billable registration the package hangs all billing state on, the three
ways a user is mapped to "the one who pays", and the tenancy, invoice-profile and
erasure rules that come with it. Read it before you configure the billable
block in config/larsasub.php.
- The BillableModel contract
- The Billable intermediary
- Direct and indirect billing
- Explicit mode
- Dual billing
- BillableResolver
- The invoice profile
- Tenant-scoped registrations
- The morph map
- Erasing a billable
The BillableModel contract
A model becomes billable by implementing Contracts\BillableModel and using
Traits\IsBillableModel:
use PetersDevelopment\LarsaSub\Contracts\BillableModel;
use PetersDevelopment\LarsaSub\PaymentProviders\Mollie\Traits\IsMollieBillableModel;
use PetersDevelopment\LarsaSub\Traits\IsBillableModel;
class User extends Authenticatable implements BillableModel
{
use IsBillableModel;
use IsMollieBillableModel;
public function getBillableNameAttribute(): string { return $this->name; }
public function getBillableEmailAttribute(): string { return $this->email; }
public function getBillableIdentifierAttribute(): string { return (string) $this->id; }
}
The contract asks for the three accessors above plus getNotifiableUsers(), the
people who receive the subscriber's billing mail. IsBillableModel implements
that one for you: the model itself when it uses Laravel's Notifiable,
otherwise its billable_email as a mail route. Override it to mail team owners
or billing contacts instead — see Notifications.
IsMollieBillableModel is required with the Mollie provider: the Billable
intermediary delegates getMollieCustomer() and friends to the owner model.
For activeSubscription(), hasFeature() and featureValue(), add
Traits\HasSubscriptions as well — see Traits and helpers.
Boot refuses a billable.model (or billable.platform_model) that does not exist
or does not implement BillableModel.
The Billable intermediary
LarsaSub does not hang billing state off your model directly. It uses a
Models\Billable record in larsasub_billables as an intermediary:
Owner (User, Team, Tenant)
↓ owner_id / owner_type (polymorphic)
Billable (larsasub_billables)
↓
BillableSubscription · RecurringCharge · SinglePayment · BillableCredit · MollieCustomer · Invoice
Every billing entity references the Billable's id, never the owner's. That is
what lets the same package serve a User-billed app and a Team-billed app
without either schema knowing about the other, and what makes per-tenant owner
databases possible at all.
Registrations are created lazily by BillableResolver::getOrCreateBillable() on
the first billing operation — you never create one by hand.
The registration is also what the package hands around on paths without a
request — the notification queue, the scheduler, webhooks — and it answers for
its owner there: getNotifiableUsers(), getNotifiableUsersFor($notification),
getTenantNotifiableUsers() and getTenants() on a Billable delegate to the
owner model, so the owner's own implementation is what runs, once the owner is
reachable. See Notifications.
Each registration carries a profile_code: a unique, human-quotable code
(A7K3MX2P, or LCRA-A7K3MX2P when a tenant reference code applies) used across
the dashboards and in support conversations. It also stores the customer's
locale, captured from your owner model — see
The customer's locale.
Working with a registration
$billable->owner; // your model (may be unreachable outside a tenant context)
$billable->tenant; // the stored tenant, or null
$billable->subscriptions; // BillableSubscription
$billable->recurringCharges;
$billable->singlePayments;
$billable->credit; // BillableCredit
$billable->mollieCustomers;
$billable->resolveTenant(); // the stored tenant first, the owner's relation as fallback
$billable->isPlatformBillable(); // dual billing: owned by billable.platform_model
$billable->payableCharges(); // what the customer can pay manually right now
$billable->outstandingCharges(); // unpaid charges to show — a show list, not a pay list
Payable and outstanding charges and mandate setup status are described on their own pages.
Direct and indirect billing
In the default auto resolution mode, BillableResolver::resolve($user) maps
the authenticated user to exactly one billable owner, in one of two ways:
'billable' => [
'model' => \App\Models\User::class,
'relation' => null, // direct: the User IS the billable
// 'relation' => 'team', // indirect: $user->team() is the billable
],
Direct is the default: the authenticated model holds the subscription.
Indirect points at a relation name on the authenticated model. Several users
then share one billable — the team/company scenario. The relation must return a
model that itself implements BillableModel and uses IsBillableModel, and
billable.model names that class:
'billable' => [
'model' => \App\Models\Company::class,
'relation' => 'company',
],
A user whose relation returns null has no billable owner (resolve() returns
null). A relation method that does not exist, or that returns a model not
implementing BillableModel, throws a BillableResolutionException.
Explicit mode
Apps where one authenticated user can own many billables — a multi-tenant SaaS where each tenant is its own billable and the current one comes from the URL subdomain — should use explicit mode:
'billable' => [
'model' => \App\Models\Tenant::class,
'relation' => null,
'resolution_mode' => 'explicit', // or LARSASUB_BILLABLE_RESOLUTION_MODE=explicit
],
In explicit mode:
BillableResolver::resolve($user)throws aBillableResolutionException, unlessbillable.owner_resolveris configured. There is no single "the billable" for a user, so any code callingresolve()without that hook is bugged and should hear about it.- You pass the owner explicitly everywhere else — see Passing the owner yourself.
billable.relationis ignored.billable.modelstill names theBillableModel-implementing class and is validated at boot.
Boot refuses a resolution_mode other than auto or explicit.
The owner resolver
billable.owner_resolver is the optional bridge back to the bundled UI. It
names an invokable class that returns the current owner from your own request
context:
use Illuminate\Contracts\Auth\Authenticatable;
use Illuminate\Database\Eloquent\Model;
class CurrentTenantOwnerResolver
{
public function __invoke(?Authenticatable $user): ?Model
{
return app()->bound('currentTenant') ? app('currentTenant') : null;
}
}
'owner_resolver' => \App\Support\CurrentTenantOwnerResolver::class,
Return null when there is no current owner (landlord routes, for example);
anything else must be a model implementing BillableModel. With the hook
configured, ownership policies and the bundled dashboards work in explicit mode.
Without it — or when the hook throws or returns something else — ownership
checks fail closed: they deny, never 500. Boot refuses an owner_resolver
that is not an existing invokable class. A headless host that calls the services
itself does not need it: the service calls that check ownership take
skipOwnershipCheck: true (below).
This resolver reads request state on purpose. Do not copy the pattern to
notifications.payment_urlornotifications.first_payment_url: those run in the queue worker, where there is no request and no current tenant.
Passing the owner yourself
The subscribe entry points take the owner model (or the Billable itself), so
you hand them the owner your own middleware resolved:
$billable = BillableResolver::getOrCreateBillable($tenant);
$provider->createSubscription($tenant, $plan, $paymentMethod);
$subscription->belongsToBillable($tenant); // ownership check without a resolvable user
- Ownership checks:
ownedByUser()needs a resolvable user, so in explicit mode without a resolver it always answersfalse. UsebelongsToBillable($billable), which takes theBillable, the owner model or a raw id — see HasBillableOwnership. - Cancelling:
cancelSubscription()checks ownership against the authenticated user unless you passskipOwnershipCheck: true. - Retry and terminate after a failed first payment:
retryFailedFirstPayment()andterminateFailedSubscription()check the authenticated user too, and take the sameskipOwnershipCheck: true. - The pay button in mails: set
notifications.payment_urlto a URL with{charge}, or an invokable class. Without it the payment-failed, chargeback and reminder mails link to the bundled payments dashboard, which shows the billable of the signed-in user — with several per user, not necessarily the one the mail is about. Boot warns when the reminder series is on and the key is empty. See Action button URLs.
See Subscriptions.
Dual billing
When the platform bills tenants and tenants bill their own users, set both:
'billable' => [
'model' => \App\Models\User::class, // tenant's users → Connect account
'platform_model' => \App\Models\Organization::class, // platform bills this → global key
],
MollieClientResolver routes on that distinction: payments for
platform_model go through the platform's own Mollie key, payments for model
through the tenant's Connect OAuth token. Both classes must implement
BillableModel and use IsBillableModel. Application fees are skipped for
platform self-transactions. See Mollie Connect.
BillableResolver
Helpers\BillableResolver maps users to owners and owners to registrations:
use PetersDevelopment\LarsaSub\Helpers\BillableResolver;
// Owner-model resolution (auto mode, or explicit mode with an owner_resolver)
$owner = BillableResolver::resolve($user); // User, or Team/Company per config
$owner = BillableResolver::fromRequest($request); // the user of the package's own guard
$direct = BillableResolver::isDirect();
// Billable registrations
$billable = BillableResolver::getBillable($owner); // read-only; null when absent
$billable = BillableResolver::getOrCreateBillable($owner); // lazy creation, for write contexts
// Connect helpers
$tenant = BillableResolver::deriveTenant($owner); // via connect.tenant_relation
$code = BillableResolver::resolveTenantCode($owner); // e.g. 'LCRA', or null
getBillable() and getOrCreateBillable() both take an optional second
argument, the tenant. Pass it whenever you are outside request context — see
Tenant-scoped registrations.
Use getBillable() in read paths so a viewer never mints a registration as a
side effect of looking at a page, and getOrCreateBillable() in write paths
(subscribing, paying, saving a profile). getOrCreateBillable() throws a
BillableResolutionException for an owner that does not implement
BillableModel, and refreshes the stored locale from the owner on every call.
fromRequest() reads the guard the package is configured for
(standalone.guard), not the default one.
The invoice profile
The registration stores the billing profile the invoices are made out to:
| Field | Notes |
|---|---|
invoice_type |
personal or business — the B2B marker |
invoice_first_name, invoice_last_name, invoice_email |
|
invoice_company_name, invoice_vat_number, invoice_coc_number |
Business profiles only |
invoice_address_line_1, invoice_address_line_2, invoice_city, invoice_postal_code, invoice_state |
|
invoice_country |
ISO 3166-1 alpha-2 — the source of truth for every VAT decision |
vat_check_status, vat_check_number, vat_check_reference, vat_checked_at |
The VIES check of the VAT number (opt-in) — written by the package only, never fillable; see Validating VAT numbers |
$billable->invoiceDetails; // the assembled array the invoice snapshot reads
$billable->populateDefaultInvoiceDetails(); // seed empty fields from the owner model
invoice_type is load-bearing, not cosmetic. It decides both whether the three
business fields are part of the invoice snapshot and whether the EU reverse
charge can apply.
Switching away from business clears Billable::BUSINESS_ONLY_FIELDS on every
model save. That is enforced on the model rather than in one service, so host
code, Nova and Filament are covered too, and a form that hides those inputs but
still submits them cannot resubmit a stale VAT number.
Write the profile through the model. Query-builder mass updates dispatch no model events and bypass that rule, exactly as they bypass the immutability guards. It is survivable — the zero rate needs
invoice_type === 'business'as well, so a surviving number grants nothing on its own — but prefer model saves.
Whether a supplied VAT number is genuine is what the opt-in VIES check answers
(invoicing.vat_validation): the same model hook resets the check on every change
of type, country or number — a re-spelling of a number VIES confirmed keeps
the confirmation — and, with the check on, queues a new one after the save
commits. With the check on, only a number VIES confirmed is reverse charged. See
Validating VAT numbers and
Compliance.
Tenant-scoped registrations
larsasub_billables carries tenant_type/tenant_id plus a non-null
tenant_key ("{morph}:{id}", '' for no tenant) that is part of the unique
owner key. Customer #1 of tenant A and customer #1 of tenant B therefore get
distinct registrations — which is what makes per-tenant owner databases safe,
where owner ids are no longer globally unique.
When you omit the $tenant argument, the tenant is derived from the owner's
connect.tenant_relation — in Connect mode only, and never for a
billable.platform_model owner — which only works in request context. Outside
it (webhooks, queue workers, scheduled commands) pass the tenant explicitly.
Billable::resolveTenant() prefers the tenant stored on the registration.
That read never touches the owner model, which is what breaks the chicken-and-egg
of needing the owner to find the tenant whose database the owner lives in.
Platform billables and simple-mode registrations have tenant_key = '', unless
you passed a tenant to getOrCreateBillable() yourself. Owner-side relations
(larsasubBillable(), mollieCustomers(), …) filter on the derived tenant key
automatically, so they find a registration only under the tenant that
derivation names.
If your owner models live in per-tenant databases, read
Multi-database tenancy next — it covers the
connect.tenant_context hook that the webhook, queue and scheduler paths need.
The morph map
LarsaSubServiceProvider::boot() registers a Relation::morphMap() alias for
every package model (larsasub_subscription, larsasub_billable, …). This keeps
class names out of morph columns and makes the package work in host apps that
call Relation::enforceMorphMap() — without the host having to enumerate
LarsaSub's models.
You only map your own models, for example 'user' => User::class to cover
larsasub_billables.owner_type. A package model you already mapped under an
alias of your own keeps it: the package registers no second alias for that
class, so existing rows and new writes use the same one.
Erasing a billable
anonymize() implements the erasure side of a GDPR request. The registration row
itself survives, because the billing history hangs off it.
$billable->anonymize();
It throws a LarsaSubException while the billable has an active or upcoming
subscription — cancel or end it first. Otherwise, in one transaction, it:
- clears the invoice profile, its VIES check and the stored locale, and sets
anonymized_at; - clears the frozen
vat_customersnapshot on charges and single payments (soft-deleted rows included); - strips the personal data the provider returned — account holder name, IBAN, BIC, addresses, card holder, card number digits, card fingerprint and PayPal payer id — from payment, refund and chargeback details and from mandates, keeping what reconciliation needs;
- deletes the registration's own model logs and its notification-delivery rows.
It leaves the facts a tax authority needs — amounts, rates, VAT treatment, reverse-charge flags — intact, and invoices and credit notes keep their own frozen customer snapshot under the fiscal retention duty.
What it does not do is decide when erasure is lawful, erase data held at the payment provider, or erase anything in your own tables. Read Compliance before wiring it to a button.