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

Public API and deprecation policy

What you may depend on in LarsaSub, what you may not, and what happens when something has to change. Read it before you build on anything beyond the documented services, and before you upgrade across a minor release. The boundary is enforced by a test in the package (PublicApiBoundaryTest), so it cannot drift silently.

What is public

These are the surfaces your application is meant to use. They follow the deprecation policy. Anything not listed here, and anything marked @internal in the source, is internal — see What is internal.

Surface Namespace or symbols What you do with it
Contracts Contracts\* Implement them — see Contracts
Models Models\*, PaymentProviders\Mollie\Models\* Query them, relate to them, listen to their events. Columns are part of the contract; a removal is breaking. See Model reference
Events Events\* Listen to them. Constructor arguments are part of the contract. See Events
Parameter names Documented methods The docs call methods with named arguments (refund(amount: …, idempotencyKey: …)), so renaming a documented parameter is breaking
Exceptions Exceptions\* Catch them. Every one extends LarsaSubException — see Exception hierarchy
Services Service\* Call the documented entry points — see Services
Traits Traits\*, PaymentProviders\Mollie\Traits\IsMollieBillableModel Put them on your own models — see Traits and helpers. PaymentProviders\Mollie\Traits\HandlesMollieWebhooks is not one of them: it is webhook plumbing for the package's own Mollie controllers
Notifications Notifications\* Class names are stable: reference them in Notification::fake() assertions, route them yourself, and match on them in the notifications.attachments invokable. Their internals are not — override the Blade views, not the classes
Mail attachments notifications.attachments false, 'invoice' or an invokable — see Mail attachments
Provider seam PaymentProviders\Contracts\*, PaymentProviderFactory Register your own provider through larsasub.providers. May still change in a minor release — see Provider seam
Provider access PaymentProviderFactory, MollieClientResolver, MollieConnectService, MollieClientContext The supported routes to the provider and the Connect flow — see Provider access
Selected helpers Helpers\BillableResolver, Helpers\CountryHelpers The documented resolution and country APIs — see Traits and helpers
Config parsers Helpers\ConfigHelpers::parseMiddleware(), parseContentLocales(), parseAdminRole(), parseNotificationRecipients(), parseNotificationChannels() Called from the published config/larsasub.php, which is your own file — renaming one would break every published config. The rest of ConfigHelpers is internal
Mail theme helpers Support\EmailColor::firstColor(), isSafeCssColor(), readableInkOn() The method names are contract because the published emails/layout.blade.php calls into this class (today firstColor() and readableInkOn()), and a published view never receives a correction. Not something to call from your own code: the class is @internal for every other purpose, and the surface you configure is email_theme.*
Dashboard asset helper Helpers\GeneralHelpers::dashboardAssetUrl() The method name is contract because the published partials/standalone-head-assets.blade.php and partials/standalone-scripts.blade.php call it, as EmailColor above. The rest of GeneralHelpers is internal
Terms support Support\TermsDocument, Support\FileTermsProvider Construct and extend these — see Terms acceptance
Payment value objects Support\PaymentRefund, Support\PaymentChargeback, Support\RefundRequest Read them; check isBookable() before you book — see Payment value objects
Cost-line change set Support\CostLineChangeSet, Support\CostLineChange, Enums\CostLineChangeType, Enums\CollectionNotice The payload of SubscriptionCostChangedEvent — see Cost-line change set
Recurring amount Support\MonthlyAmountSummary What a subscriber is debited per period — see Recurring amount
First-charge quote Support\ChargeQuote, Support\ChargeQuoteLine, Support\VatProfile, Enums\VatBasis What a checkout button must name — see First-charge quote
Before-first-charge seam Support\BeforeFirstChargeHook Custom providers call resolve() — see Before-first-charge seam
Moving a start date BillableSubscriptionService::moveStartDate() The only supported way to change start_date — see Moving a start date
State enums Enums\SubscriptionState, Enums\DeactivationReason, Enums\ActivationSource, Enums\NotPayableReason, Enums\MandateRemovalBlocker, Enums\MandateSetupStatus, Enums\VatTreatment, Enums\RefundBlocker, and the dunning enums Switch on them — see State enums
Attention markers ProviderPaymentModel::*_MARKER, RecurringCharge::*_MARKER Tell why a payment is flagged — see Attention markers
Registration teardown Service\BillablePruneService, Enums\OrphanBlocker See Registration teardown
Valued-window repair Service\ValuedFromBackfillService, Enums\ValuedFromBlocker, Support\ValuedFromReconstruction See Valued-window repair
Mandate self-healing Service\PaymentReconciliationService::recoverMandateFor() See Mandate self-healing
Connect disconnect Support\DisconnectImpact, Support\DisconnectFinalizeResult, Events\Connect\MollieDisconnectRequestedEvent, Events\Connect\MollieDisconnectCancelledEvent, Events\Connect\MollieDisconnectedEvent See Connect disconnect
Settlements Service\SettlementService, Models\Settlement, Support\SettlementSyncResult, Enums\SettlementSyncOutcome, Events\Settlement\* See Settlements
Reminder series Service\DunningService, Models\DunningCase, Models\DunningStep, Enums\DunningStatus, Enums\DunningOrigin, Enums\DunningClosedReason, Enums\DunningPauseReason, Enums\DunningAction, Enums\DunningStepResult, Enums\DunningPruneBlocker, Events\Dunning\* See Reminder series
VAT-number validation Service\VatNumberValidationService, Contracts\VatNumberValidator, Support\VatNumberCheck, Support\VatNumberCheckResult, Enums\VatNumberStatus, Events\Billable\VatNumberCheckedEvent, the vat_check_status / vat_check_number / vat_check_reference / vat_checked_at columns on Billable See VAT-number validation
Mollie website profile Support\MollieProfile What MollieConnectService::listProfiles() returns, and what you build a profile picker from: id, name, website, mode, status and current. Plain values — deliberately not the SDK's Profile resource
Translatable content cast Casts\TranslatableText Needed on your own model when you use the public HasTranslatableFields trait. Storage is text holding either a plain string (= every locale) or a JSON locale map; detection is anchored to locales.supported, so narrowing that config without migrating stored maps makes them unreadable
Test kit Testing\LarsaSubFixtures Saved database fixtures for your feature tests: billables, mandates with their required customer, charges (including the mandate-setup shape and chargeback follow-ups), reminder series (dunningCase()), payments, settlements and a VIES-confirmed business customer (validatedVatNumber()) — see Testing
Configuration config/larsasub.php Keys are public; a removed or renamed key is a breaking change and gets an UPGRADE.md entry
Artisan commands larsasub:* Names, options and exit codes — see Commands
Route names larsasub.* Stable; the URLs behind them are yours to configure — see Routes

Contracts

The interfaces under Contracts\* are what you implement to plug into the package.

Contract Implement it when
BillableModel Always, on the model that gets billed — see Billable models
IsPayable A business object of yours is paid for through a single payment — see Single payments
TermsProvider The bundled dashboards should ask for acceptance of your terms — see Terms acceptance
InvoiceRenderer You render or own the invoice documents yourself — see Invoicing
ResolvesNotificationRecipients Optional: who receives a customer mail depends on which mail it is — see Notifications
ProspectiveCostLines Optional, on your subscription.before_first_charge hook — see below
HasSubscriptions Optional: to type-hint against the HasSubscriptions trait — see Traits and helpers
VatNumberValidator You replace the VIES check — see VAT-number validation
HasAdminUrl, PaymentMethodControllerInterface, ProviderPaymentStatusInterface Implemented by the package's own models and webhook controllers: the admin_url attribute of subscriptions and payments, and the shape a payment provider's controllers and payment models follow — see Provider seam

ProspectiveCostLines goes on the class you configure as subscription.before_first_charge. prospectiveLines($plan, $owner) returns the lines the hook will add (the shape addLine() takes), so the bundled subscription dashboard can quote them before the subscription exists. Let __invoke() add exactly those lines. Without it, a configured hook leaves that dashboard without a subscribe button, because the amount cannot be named.

Exception hierarchy

Every exception the package throws extends LarsaSubException, so catching that catches them all.

LarsaSubException (base)
├── BillableResolutionException
├── BillableSubscriptionServiceException
├── ConnectAccountDisconnectedException (extends PaymentProviderServiceException)
├── ConnectDisconnectPendingException (extends PaymentProviderServiceException)
├── CreditServiceException
├── LicenseTierException
├── MandateServiceException
├── MandateSetupInProgressException
├── MollieOrganizationChangedException
├── MollieProfileException
├── PaymentMethodControllerException
├── PaymentProviderServiceException
├── RecurringChargeException
├── ReferenceCodeException
├── RefundServiceException
├── SinglePaymentException
├── TenantContextException
└── VatProfileRequiredException

A few carry more than a message:

Exception What it tells you
MandateServiceException A MandateRemovalBlocker in its readonly $reason — switch on it, never on the translated message
RefundServiceException A RefundBlocker in its readonly $reason, the same way
VatProfileRequiredException quoteFirstCharge() refused to name an amount without a country on VAT-exclusive prices, with invoicing on — the one quote refusal the customer can fix
TenantContextException Your connect.tenant_context hook threw, on activating a tenant or on leaving the tenant context. LarsaSub treats that as transient and retries; a tenant that no longer resolves is permanent and is skipped instead
ConnectAccountDisconnectedException The tenant has a Mollie Connect account, but it is no longer connected. A tenant without any account still gets a plain PaymentProviderServiceException
ConnectDisconnectPendingException A front door refused a new payment because the tenant's account is draining a requested disconnect

The two Connect… exceptions are the only subclasses of a subclass; a catch of PaymentProviderServiceException still catches both.

Services

Everything under Service\* is public. The entry points the documentation builds on:

Resolve SubscriptionCostService from the container and nowhere else: it is bound as a singleton because its batch() scope lives on the instance.

Mail attachments

notifications.attachments takes three shapes: false, 'invoice', or an invokable class name. The invokable is called as __invoke(string $notification, ?Model $record): Attachment[]|Attachment|null.

Provider seam

You register your own payment provider through larsasub.providers, against the contracts in PaymentProviders\Contracts\*.

The provider contracts may still change in a minor release, also after 1.0, until a second provider ships. They are shaped around Mollie today and will be reshaped when one is added. Each such change gets a CHANGELOG.md and UPGRADE.md entry. If you implement your own provider, read those notes on every minor release.

A provider tells the package it refuses new payments for a billable through PaymentProviderService::refusesNewPaymentsFor() (default false). It is a question, also asked by the customer mails while they render to decide on their pay button, so keep it free of side effects. The front doors call assertAcceptsNewPaymentsFor(), which logs the refusal and throws ConnectDisconnectPendingException. Refunds go through prepareRefund() and createRefund(), which are not abstract: a provider without them keeps working, and its payments are refused with RefundBlocker::ProviderUnsupported.

Provider access

Four classes outside the contracts are public: PaymentProviders\PaymentProviderFactory, PaymentProviders\Mollie\MollieClientResolver, PaymentProviders\Mollie\MollieConnectService and PaymentProviders\Mollie\MollieClientContext.

Payment value objects: check isBookable() before you book

Support\PaymentRefund and Support\PaymentChargeback are carried by the refund and chargeback events and the IsPayable hooks — one per individual refund or chargeback. Key your own idempotency on their id, and call isBookable() before you book the amount.

Support\RefundRequest is different: it is what RefundService::refund() returns and RefundRequestedEvent carries — a refund that was asked for, not money that moved. Never book it; isBookable() does not exist on it on purpose.

public function onPaymentRefunded(SinglePayment $payment, PaymentRefund $refund): void
{
    if (! $refund->isBookable()) {
        // The money moved, but this entry cannot be booked automatically.
        // Park it for an operator; do not treat it as €0,00.
        return;
    }

    $this->bookReversal($refund->id, $refund->amount);
}

The refund and chargeback objects are hydrated from a stored snapshot (provider_refund_details / provider_chargeback_details), and that hydration is deliberately tolerant: a legacy or partial entry degrades to empty strings rather than throwing, because the money it describes already moved. You can therefore receive an object with amount === '' or id === ''. isBookable() is the marker: it returns true only when the id is non-empty and the amount matches ^\d+(\.\d+)?$ (after trimming) with a value above zero.

Two properties of the contract are load-bearing:

The one exception: an entry with no id at all is skipped. Not because it is unbookable, but because it cannot be told apart — the package keys its once-per-occurrence delivery on that id, and every id-less entry looks like every other. Such an entry is dropped before the merge and a warning is logged. You can still meet an id-less object if you hydrate one yourself from an older stored snapshot, which is why isBookable() keeps checking the id.

Cost-line change set

Support\CostLineChangeSet is the payload of SubscriptionCostChangedEvent: everything that moved in one act, plus the settled total. Read it when you send your own notice.

Property or method What it holds
->changes The Support\CostLineChange entries, each with type (Enums\CostLineChangeType), key, description, monthlyAmount, previousMonthlyAmount, effectiveOn
->monthlyTotal What will be debited per billing period
->introMonthlyTotal The debit while an introduction period runs
->intervalMonths Which period the two totals cover
->effectiveOn When the change takes effect
->vatNotice An Enums\CollectionNotice (gross, reverse-charge, net) or null
->vatRate The rate a gross notice names, formatted ('21'); null otherwise
->shouldAnnounce() / ->announceable() Which changes are news, rather than a subscription's opening composition

monthlyAmount is the LISTED amount and monthlyTotal is what will be DEBITED. VAT moves them apart, and ->vatNotice says how. It is null on a default install, so handle null explicitly: a match without it throws. The notice is frozen with the total, not resolved when you render.

Both totals are per billing period, not per month: on a yearly plan ->monthlyTotal is the yearly debit. The two names are frozen wire format (the set rides a queued payload), so caption them with ->intervalMonths rather than with the word "month". The per-change monthlyAmount fields are unaffected: a cost line's amount stays monthly whatever the plan's interval.

Adding a case to either enum is not breaking, but the backed values are wire format twice over: they are also translation-key suffixes, so renaming one would silently orphan every published translation override.

Recurring amount

MonthlyAmountSummary::for($billableSubscription) answers what a subscriber is actually debited per billing period. It is what the nine bundled subscription mails quote (six to the subscriber, three company copies); use it when you send your own.

Property What it holds
->costLines The active cost lines
->periodTotal The total, VAT-converted exactly as the charge will be
->introPeriodTotal The total while an introduction period runs
->intervalMonths The billing period in months
->vatNotice An Enums\CollectionNotice labelling how the total relates to the listed prices (null = they are the same)
->vatRate The rate a gross notice names, formatted ('21'); null otherwise
->netPeriodTotal, ->vatAmount, ->introNetPeriodTotal, ->introVatAmount Only under a gross notice: the subtotal/VAT split that reconciles the listed rows with the inclusive total. null otherwise. The VAT amount is the subtraction of the two already-rounded totals, so the three figures always add up exactly

toViewData() returns the exact keys the bundled mail views receive, including the perPeriod / perMonth captions. The class name is kept for continuity; the figures are per period. Upgrading from before 0.19.0: see UPGRADE for the renamed properties.

First-charge quote

SubscriptionCostService::quoteFirstCharge($plan, $owner, $prospectiveLines, $startDate, $profile) returns a Support\ChargeQuote: the amount a checkout button must name, computed by the same core the real charge runs through. It writes nothing.

Print ->lines row by row and ->total as the total — never sum the rows and convert yourself. VAT is converted per row and re-summed, so an aggregate conversion drifts up to half a cent per row. Under a gross notice only, ->netTotal / ->vatAmount (and ChargeQuoteLine::$netAmount) carry the split, by the same rule MonthlyAmountSummary uses; under reverse charge that subtraction would be a negative VAT.

Read the three flags before you print the total:

Flag Meaning
->uncollectible The plan collects nothing; total is 0.00 and the rows are informational
->creationRefused subscription.free_plan_mandate = 'refuse' applies to this plan and this subscriber, so createSubscription() will throw — do not render a checkout button. A quote for a prospect with no registration reads as refused, which is what the subscribe flow will do
->linesIncomplete A subscription.before_first_charge hook is configured and no prospective lines were passed, so what it adds is missing

->isComplete() checks all three at once.

The VAT outcome is on the quote, which is why VatHelpers stays internal:

A VatProfile you pass wins over the one stored on the Billable, and the charge takes no profile at all. A wizard states what the customer is about to buy under, so persist that profile before you collect, or the quote and the charge disagree. Passing none for an owner that has a registration is always safe.

A profile is required when invoicing is on (invoicing.enabled) with invoicing.prices_include_vat false and no country is stored: without a country the package refuses to gross up, so the quote would be short by the full VAT rate. It throws VatProfileRequiredException instead of answering low. With invoicing off, with VAT-inclusive prices, or for a plan that collects nothing, a quote without a profile is safe.

In Connect mode the tenant is the seller and half of every VAT decision. The quote takes it from the registration, then the owner's connect.tenant_relation, then the plan — so pass the $owner for a tenant-scoped sale, also on a global plan, which names no tenant of its own.

Scope: the first charge of a new subscription only — not the next collection on a running one, not a plan change, and credit is not deducted. See Quoting the first charge.

Before-first-charge seam

Support\BeforeFirstChargeHook lets your application put cost lines on a new subscription so its first charge collects them. You normally never name this class: pass a ?Closure $beforeFirstCharge to PaymentProviderService::createSubscription() / createSubscriptionWithMandate() / BillableSubscriptionService::changeSubscription(), or configure subscription.before_first_charge.

Moving a start date

BillableSubscriptionService::moveStartDate() is the only supported way to change start_date after creation. Writing the column yourself throws a LarsaSubException when the subscription has a charge or when cost lines would be left on the old date; the service moves those lines first. start_date and the cost lines' starts_on are one invariant: a new line is clamped up to the start date, so moving the date by hand collected the wrong amount in both directions — too little when brought forward, too much when pushed back.

A query-builder mass update still bypasses the guard, as everywhere in this package; larsasub:doctor lists the candidates it left behind.

State enums

The state enums are returned by documented methods and stored in columns. Their backed string values are stored and wire format, and never change meaning. Every enum not named under What is internal is public; the full list, with where each one appears, is in Model reference.

Enum Where you meet it
SubscriptionState BillableSubscription::state()
DeactivationReason The deactivation_reason attribute
ActivationSource The activation_source attribute, and the Activated/Started events. Nullable (older rows; host-forced activations without a source). Change is stored on the column but never accompanies an ActivatedEvent — a plan change dispatches ChangedEvent instead — and StartedEvent only ever fires from the scheduled detect command
NotPayableReason RecurringCharge::notPayableReason() — see Outstanding charges
MandateSetupStatus $owner->mandateSetupStatus(): where a mandate setup stands (none | started | parked | released | failed | confirmed). started and parked both mean in flight — never clean up under them, a late webhook can still complete a paid onboarding
VatTreatment The vat_treatment attribute on charges and single payments. A refund reads it to decide whether it may re-add VAT or has to remove it; stripped marks a zero-rated customer on VAT-inclusive prices whose VAT the package removed. Test for "no VAT" with ! includesVat(), never === Excluded
MandateRemovalBlocker Why a mandate cannot be removed: on MandateServiceException::$reason from MandateService::removeMandate(), and per mandate from MandateService::removalBlockers($owner, $tenant) (null = removable). Switch on it; never match the exception message, which is translated
RefundBlocker The same, for RefundServiceException::$reason from RefundService::refund()

Adding a case is not a breaking change, so give every match a default.

Outstanding charges: a show list, never a pay list

Two surfaces answer two different questions, and confusing them costs money.

Two figures for money received, on RecurringCharge and SinglePayment:

RecurringCharge::notPayableReason() and is_payable are the same decision tree — is_payable === (notPayableReason() === null) — so a reason is never a second opinion. But a reason is an explanation, not permission:

Never start a payment because "the reason looks harmless". That bypasses the double-payment guard. A SEPA collection is live at the provider for days; a second collection debits the customer twice and someone has to refund it.

Adding a case to NotPayableReason is not a breaking change — treat unknown cases as "not payable, reason unknown" (a match on it needs a default).

isLiveAttempt(): ask before you tear anything down

$charge->isLiveAttempt() / $payment->isLiveAttempt() (and the scope of the same name, reached as RecurringCharge::query()->isLiveAttempt()) answer whether a payment attempt is still running: claimed (charged_at written before the provider call) but neither paid nor provably failed. That covers two shapes a status check misses — an unresolved claim with no provider status yet, and a SEPA collection that settles in days, which is pending rather than open.

Any host code that deletes, anonymises or otherwise tears down a subscription, mandate or billable must wait for such an attempt to resolve. Removing the row underneath it makes the eventual paid webhook skip its work silently: money collected, nothing to book it against. The package's own teardown paths (cleanup sweeper, terminateFailedSubscription(), prune-billables) all gate on this one method, and so should yours.

ProviderPaymentModel::constrainToLiveAttempt() is @internal — it exists only so the scope and whereHas() closures can share one implementation.

Attention markers

The *_MARKER constants are the prefix an attention_message opens with, so a screen can tell why a payment is flagged without matching prose. The rest of the message is English diagnosis and not contract. Match with str_starts_with(); rows flagged before a marker existed carry none.

Constant Declared on Meaning
RECONCILIATION_PARKED_MARKER ProviderPaymentModel Reconciliation could not establish a claim's outcome at the provider
PAYMENT_START_UNCLEAR_MARKER ProviderPaymentModel A payment start failed in a way that may have created the payment; confirm at the provider before clearing
CREATED_NOTHING_MARKER ProviderPaymentModel A collection attempt that failed in a way that provably created nothing at the provider (no mandate, a request the provider rejected). A 409 is not such a rejection: Mollie sends it while an earlier request with the same idempotency key is still being processed, so that charge is flagged without this marker. It keeps such a charge manually payable: a charge claimed for collection without a provider answer on the row is otherwise PaymentInProgress. RecurringCharge::CREATED_NOTHING_MARKER resolves to the same constant
CONNECT_DISCONNECTED_MARKER ProviderPaymentModel The tenant's Mollie Connect account was disconnected while the payment was under way, or Mollie reported a change on it after that (a status, a refund, a chargeback). The package can no longer read it, and only the tenant can see in Mollie what happened; settle it by hand. It also keeps every customer mail about that payment away, and comes off by itself when a final status is booked after all (a reconnect to the same organization)
MANDATE_REGISTRATION_FAILED_MARKER RecurringCharge A paid first payment whose mandate registration threw; the self-healing pass repairs it
MANDATE_SETUP_RELEASED_MARKER RecurringCharge On a soft-deleted mandate-setup charge whose verification was abandoned; never on a live flag
FOLLOW_UP_DOUBLE_PAID_MARKER RecurringCharge A chargeback follow-up paid twice that was not given back automatically; clearing returns nothing

RecurringCharge::flagFailedCollection() writes CREATED_NOTHING_MARKER — use it, not setNeedsAttention(), when your own code flags a failed executeCharge().

RecurringCharge::awaitsStrandedClaimReconciliation() tells whether clearing a chargeback follow-up's flag hands it to the stranded-claim pass of larsasub:execute-charges (which needs the scheduler); false means nothing picks it up after clearing.

Registration teardown

Service\BillablePruneService::pruneOrphaned(int $olderThanDays = 30, bool $dryRun = false) deletes Billable registrations whose owner no longer exists and through which no money ever moved — the leftovers of a rolled-back provisional signup. The command is larsasub:prune-billables --orphaned: host-invoked, never auto-scheduled.

Every guard fails closed:

Valued-window repair

Service\ValuedFromBackfillService::backfill(bool $dryRun = false) reconstructs valued_from on cost lines that were billed before that column existed — the one-off repair behind larsasub:backfill-valued-from. See UPGRADE for when you need it.

Why it is needed. A line with no valued_from reads as starts_on, which is right for every line no opening clamp touched and exactly wrong for one a clamped first charge already billed: that line goes on refunding days nobody was ever charged for.

What it refuses. A valued_from one day too late turns a visible over-refund into a silent under-refund, so anything the amount_breakdown snapshots cannot prove is left untouched and reported, never guessed. That includes a subscription any of whose snapshots is unreadable (unreadable-snapshot), because an entry that had to be dropped could have been the one that changed the answer. One bad subscription is skipped with a reason, never fatal to the run.

Further properties:

Mandate self-healing

Service\PaymentReconciliationService::recoverMandateFor(RecurringCharge $charge): bool is the one-charge repair behind the mandate pass of larsasub:execute-charges, exposed so you can run it on a single charge without waiting for, or widening, the scheduled window.

Every precondition is checked inside the method, so it is safe to call on any charge; it returns false with a logged reason when it refuses. The charge must:

The one thing it does not re-check is scheduling.recover_mandates_within_days: that window bounds the scheduled pass, and a host repairing one aged charge by hand is the operator the window defers to.

"Refuses" and "fails" are different returns. A refusal is false, but a failure worth retrying (the provider call) comes out as a Throwable — by design, so the scheduled pass leaves the charge for the next run instead of marking it done. Calling this from a request or a job means catching it yourself; false alone is not the whole answer.

The tenant-context hook is the exception: a hook that throws (on a tenant, or on leaving the context for a platform charge) is logged and answers false; the charge keeps its flag, so the scheduled pass tries again next run.

It can create a mandate at the provider, not only adopt one. On the Dutch flow LarsaSub itself makes the mandate from the IBAN of the first iDEAL payment, so a registration that never completed left nothing to adopt. The subscriber gets the ordinary "payment method added" mail for it.

Connect disconnect

What the disconnect methods of MollieConnectService return and dispatch — see Disconnecting a tenant.

Settlements

The provider's payouts, mirrored by larsasub:sync-settlements — see Settlements.

Reminder series

The reminder series is off by default (dunning.reminders.enabled) — see Switching it on.

Events. The five events (DunningStartedEvent, DunningReminderDueEvent, DunningEscalatedEvent, DunningExhaustedEvent, DunningResolvedEvent) are at least once: key on (event class, case, step), and re-read $event->case->fresh()->status — act unless it is resolved; the last step's events read exhausted. See Dunning events.

Mails. The reminder mails are Notifications\Dunning\{User,Company}\DunningReminderNotification; the listener class names (SendDunningReminderTo{User,Company}Listener) are what notifications.disabled_listeners takes.

Models. Read DunningCase and DunningStep freely — their columns and every enum's backed values are contract, documented in Reminder series. Write only through DunningService: close($case) is the one act for a host. It stops further reminders for that charge, for good; it does not change whether the charge can be paid; and it returns false when the case is already resolved or the charge is no longer owed (paid, or a reversed chargeback).

Reading.

DunningPruneBlocker values are the skipped keys of larsasub:prune-dunning-cases.

VAT-number validation

The VIES check is opt-in (invoicing.vat_validation) — see Validating VAT numbers. With the check on, the EU reverse charge applies only to a number whose status is valid for that very number; until then the customer is charged VAT.

Service\VatNumberValidationService:

Method What it does
statusFor($billable) The status that belongs to the billable's current number: null when none applies, it was never checked, or it was decided for another number. Never read vat_check_status alone
checkNow($billable) Asks now, synchronously (up to timeout seconds), and returns that status. Call it outside your own transactions
check($billable) Returns the validator's raw answer
checkDue(?int $limit = null, bool $uncheckedOnly = false) The sweep behind larsasub:check-vat-numbers, for a host running its own scheduler. Its report keys taken, valid, invalid, unavailable, skipped, errors are contract

VatNumberValidationService::REFERENCE_MAX_LENGTH is the width of the consultation-number columns; a longer number is logged and not stored, never cut.

Replacing VIES. Implement Contracts\VatNumberValidator. You receive a Support\VatNumberCheck — the VIES country code (EL for Greece) and the normalized national number, plus the seller as requester (both null without one) — and return a Support\VatNumberCheckResult: valid(), invalid() or unavailable(). "No answer" is unavailable(), never an exception; one thrown counts as that.

The check columns on Billable are written by the package only and are not fillable. Events\Billable\VatNumberCheckedEvent fires when a status changes.

Chargeback reversals on IsPayable

IsPayable has a seventh hook, onPaymentChargebackReversed(SinglePayment $payment, PaymentChargeback $chargeback). Every IsPayable implementation must have it: the interface is not optional, and PHP fails at class load without it.

A card chargeback is not final: the provider reverses it when the money comes back. A host that booked the chargeback as a negative payment line has to book it back, or the invoice stays reopened and the customer keeps being chased for money that was returned.

public function onPaymentChargebackReversed(SinglePayment $payment, PaymentChargeback $chargeback): void
{
    // Book back exactly what onPaymentChargeback() booked for this id.
    $this->unbookReversal($chargeback->id);   // $chargeback->reversedAt is filled
}

Three properties of the delivery:

The charge- and payment-level counterparts are RecurringChargeChargebackReversedEvent and SinglePaymentChargebackReversedEvent, both carrying the same value object.

Known limitations. A credit note the package already issued for the chargeback is not clawed back: there is no debit-note mechanism, and correcting an issued document is operator territory; the reversal only stops that amount from inflating later credit-note deltas. And the mirror case on the refund side is silent: a refund that goes from refunded to failed is a terminal-to-terminal move, so it is stored but delivers nothing, leaving a host that already booked the credit with no signal to undo it.

The provider contract's $beforeFirstCharge parameter

This matters only if you implement your own provider; hosts on the bundled Mollie provider are unaffected.

PaymentProviderService::createSubscription() and createSubscriptionWithMandate() both take an optional ?Closure $beforeFirstCharge = null as their last parameter. They are abstract, so an implementation that does not declare it fails at class load.

public function createSubscription(
    Model $billable,
    Subscription $subscription,
    PaymentMethod $paymentMethod,
    ?Closure $beforeFirstCharge = null,
): RedirectResponse {
    $billableSubscription = $this->billableSubscriptionService->createSubscription(
        $billable,
        $subscription,
        $paymentMethod,
        // Resolves the `subscription.before_first_charge` fallback when no
        // closure was passed. The service never reads config itself — that is
        // what keeps the deferred-mandate path hook-free.
        beforeFirstCharge: BeforeFirstChargeHook::resolve($beforeFirstCharge),
    );

    // … generate the first charge, start the payment
}

Declaring the parameter is enough to load; resolving it is what makes the feature work. A provider that accepts the closure and drops it leaves every host on it with a checkout that under-collects period 1: the modules are not lost (the next charge catches them up), but the money is a month late and the first invoice does not show them.

The hook runs with the freshly created subscription, inside the creating transaction, before the first charge is priced. The package wraps it in SubscriptionCostService::batch() and converts any throw into a BillableSubscriptionServiceException with a translated message and the original as getPrevious() — see Cost lines on the first charge.

The money model: floats, rounded to cents at every boundary

Every amount the package calculates in the public API — addLine(), monthlyTotal(), periodTotal(), createStandaloneCharge(), the events' totals — is a PHP float, and every boundary rounds to cents (round($x, 2)).

One deliberate exception: PaymentRefund::$amount and PaymentChargeback::$amount are the provider's own amount strings, passed through unparsed precisely because they may be non-numeric. Never cast them blindly; ask isBookable() first, exactly as the delivery contract says.

This is a deliberate, package-wide choice. Amounts enter with two decimals, arithmetic error only becomes visible beyond ~9×10¹² euro, and a single "exact decimal" island would disagree with the float-computed charge totals that feed it. If your own code has a "no floats for money" rule, keep your internal chain decimal if you wish: the package boundary is float-with-cent-rounding by contract, and passing two-decimal values through it loses nothing.

Wrapping a LarsaSub call in your own transaction

You may — with one category of exception, and then one rule.

The provider entry points belong OUTSIDE your transaction. PaymentProviderService::createSubscription(), createSubscriptionWithMandate(), createMandateSetupPayment(), startManualChargePayment() and createSinglePayment() all end in an HTTP call to Mollie. The package commits before it makes that call, on purpose: no lock of its own is ever held across a provider round trip. Your transaction defeats that. GeneralHelpers::transaction() nests as a savepoint, so the outer transaction is still open and the billable's registration stays locked for the whole round trip. Worse, a rollback afterwards deletes a charge whose checkout now exists at the provider — the customer can still pay it, and there is nothing left to match the webhook to.

Do your own work in its own transaction, commit, and then call the provider. The same goes for BillableSubscriptionService::changeSubscription() and retryFailedFirstPayment(), which execute at the provider after their own commit, and for RefundService::refund(), which locks the registration to check the ceiling and then asks the provider for the refund.

For everything else: every service method either opens its own transaction or is safe inside one (Laravel nests them as savepoints). One rule comes with it, and it is the only thing the package cannot check for you:

Take no lock in a billable's subtree before you call in.

LarsaSub serializes every lifecycle path on one row — the larsasub_billables registration — and takes it first, before subscriptions, charges, credit, invoices and the invoice-number sequence. That is what keeps the paid webhook, the schedulers, a plan change and a GDPR erasure from deadlocking against each other. Your transaction joins that order, or it breaks it:

// Deadlock risk: the cost-line UPDATE is held while endLine() reaches for the
// registration to write the refund credit — the opposite order from the
// charge planner.
DB::transaction(function () use ($subscription) {
    Module::where('subscription_id', $subscription->id)->lockForUpdate()->get();
    app(SubscriptionCostService::class)->endLine($subscription, 'module:agenda');
});

// Safe: your own tables are not in the subtree, and LarsaSub takes the
// registration itself.
DB::transaction(function () use ($subscription) {
    app(SubscriptionCostService::class)->endLine($subscription, 'module:agenda');
    $this->auditLog->record(...);
});

"In the subtree" means any larsasub_* row hanging off a billable_id — subscriptions, charges, single payments, credit, cost lines, invoices, mandates. Your own tables are not, whatever they reference. If you genuinely need a LarsaSub row locked before the call, lock the registration first: Billable::lockSubtree($billable).

The same rule applies to code the package calls back into, because it runs with that registration locked: IsPayable hooks and the refund/chargeback hooks (under the paid webhook), and the before_first_charge invokable (under subscription creation). Keep them cheap and database-only — no HTTP calls, no queue dispatch that waits, and nothing that locks its way back into the subtree.

What is internal

Marked @internal in the source. These exist to make the package work, not to be extended or called by your code. They can change in any release, including a patch, with no upgrade note:

If you need something that is internal, open an issue: that is a signal the public surface is missing something, and adding an API is cheaper for both of us than you pinning a version.

Deprecation policy

While the package is 0.x (now): minor releases may contain breaking changes. Every one is listed in CHANGELOG.md under Changed and gets an UPGRADE.md section with the migration path. Patch releases never break a public surface.

From 1.0 onward:

  1. Public surfaces change only in a major release.
  2. A removal is announced one minor release ahead: the old surface keeps working, is marked @deprecated with the replacement named in the annotation, and the deprecation is listed in CHANGELOG.md.
  3. A minimum of one minor release sits between the deprecation and the removal, so an application that tracks minors always has a release in which both old and new work.
  4. Internal surfaces (@internal) are exempt from all of the above, and so are the provider contracts until a second provider ships (see Provider seam).
  5. Security fixes may break a public surface in any release. The reason is stated in the changelog entry.

Nothing is deprecated at the moment.

Schema changes deserve their own note: since 0.37.1, every schema change ships as a new migration and existing migrations never change. Up to 0.37.1 they were edited in place, so a database created from an earlier 0.x release can miss columns; larsasub:doctor finds them (see Keeping the schema in step).