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
- Contracts
- Exception hierarchy
- Services
- Mail attachments
- Provider seam
- Provider access
- Payment value objects: check isBookable() before you book
- Cost-line change set
- Recurring amount
- First-charge quote
- Before-first-charge seam
- Moving a start date
- State enums
- Outstanding charges: a show list, never a pay list
- isLiveAttempt(): ask before you tear anything down
- Attention markers
- Registration teardown
- Valued-window repair
- Mandate self-healing
- Connect disconnect
- Settlements
- Reminder series
- VAT-number validation
- Chargeback reversals on IsPayable
- The provider contract's $beforeFirstCharge parameter
- The money model: floats, rounded to cents at every boundary
- Wrapping a LarsaSub call in your own transaction
- What is internal
- Deprecation policy
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:
BillableSubscriptionService,SubscriptionCostService,CreditService,SinglePaymentService,InvoiceService;RecurringChargeService::createStandaloneCharge();RefundService::refund()andRefundService::ceilingCents()(a stored-state hint, no guards) — see Refunds;CreditService::returnAppliedCredit()andCreditService::returnCollectedAsCredit();DunningService::close(), and the services named in the sections below.
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.
- You get the notification's class name (public, see the table above) and
the record the mail reports — a
ProviderPaymentModelornull— never the notification instance, whose properties are internal. - It runs in the queue worker inside
toMail(). Anything thrown is caught and the mail goes out without the attachment, because the alternative is no mail at all. Return[]rather than throwing for a file you cannot produce. attachmentSubject()on the notification supplies$record, andattachesPackageInvoice()decides whether'invoice'attaches its invoice. Both are internal plumbing, not something to call or override from your code.
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 aCHANGELOG.mdandUPGRADE.mdentry. 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.
MollieClientResolveris the only supported route to the Mollie API. It picks the right client for simple mode, a Connect tenant or the platform; callingMollie::api()directly silently uses the wrong account in Connect mode.MollieClientResolver::platformOrganizationContext()is the client onsettlements.platform_access_token, for what Mollie serves to organization tokens only.MollieClientContext::testmodeParams()supplies the testmode argument for calls that take it separately.MollieConnectServiceruns the tenant OAuth flow.listProfiles()andsetProfile()let a tenant pick the website profile their subscribers pay on. Disconnecting goes throughrequestDisconnect()(drain first: the account stays connected until nothing awaits its outcome, and nothing new starts meanwhile),cancelDisconnect(),disconnectNow()(at once; flags what is under way),disconnectImpact()(read only) andfinalizeDisconnects()(the pass that ends drains, run bylarsasub:execute-charges) — see Disconnecting a tenant. There is nodisconnect().PaymentProviderFactoryreturns the contract type (PaymentProviders\Contracts\*); the Mollie implementation behind it is internal.
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:
amountis a provider string, passed through unparsed. It is not guaranteed to be a decimal number and is not safe to hand straight to bcmath (which throws on a non-numeric operand) or to a decimal cast. Mollie always sends a plain decimal; a legacy snapshot or a future non-Mollie provider need not. Nothing normalizes it to'0.00'— that would turn visible corruption into a silent zero reversal. Integer amounts pass too (zero-decimal currencies such as JPY); the negative figure in Mollie's payload is the deprecatedsettlementAmount, which the package never stores here.- Delivery also happens for non-bookable value objects.
falseis not a reason to drop the event: the money moved regardless, and suppressing the hook would hide a real occurrence. An unparseableamounttherefore still reaches you, andisBookable()is the only way you see it.
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 |
monthlyAmountis the LISTED amount andmonthlyTotalis what will be DEBITED. VAT moves them apart, and->vatNoticesays how. It isnullon a default install, so handlenullexplicitly: amatchwithout 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:
->vatRate,->treatmentand->reverseCharge;->collectionNotice(anEnums\CollectionNotice).->vatBasis(Enums\VatBasis) says what the outcome rests on:Stored(the record the charge will read),Supplied(the profile you passed — persist it) orAssumed(configuration alone;->vatBasis->isGuess()is true).->vatNumberStatus(anEnums\VatNumberStatus,nullby default) is set only when the reverse charge hinges on the VIES check — the check is on and the sale is cross-border EU B2B.validis the reverse-charged quote, anything else carries VAT, and a number not checked yet (a suppliedVatProfilethat does not restate the stored, verified number, or one saved before the check was switched on) readspending.
A
VatProfileyou pass wins over the one stored on theBillable, 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.
- Custom providers must call
BeforeFirstChargeHook::resolve($beforeFirstCharge)and pass the result toBillableSubscriptionService::createSubscription(beforeFirstCharge: …). Skipping it silently drops the config fallback for every dashboard host on that provider — see the provider contract's parameter. run()is the package's own invocation site (batch wrap and error contract), not something you call.configuredClass()is the single reader of the config key (blank — null,'', whitespace — means off), and it is what the boot validation asks, so the validator and the resolver cannot disagree about whether a hook exists.
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.
- It refuses outright on a subscription that has any charge. Billing
periods are derived from
start_dateand nothing records which days a charge covered, so moving it could hand the planner a window that overlaps days already paid — a second base-price charge. - On an unbilled subscription it moves the lines that sat on the old start
date, leaves a line that deliberately starts later alone, and refuses when a
line that would have to move has already been billed (moving it would
contradict its own invoice history) — a
BillableSubscriptionServiceExceptionnaming the line. - No subscriber notification: no amount changes, only the date they start running on.
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.
$billable->payableCharges()/$owner->payableCharges()— what this customer MAY pay right now. Bind your pay button to this list (or, per charge, tois_payable). It carries the recovery-only contract: only charges the scheduler attempted or flagged, never live-at-provider ones.$billable->outstandingCharges()/$owner->outstandingCharges()— what this customer still OWES: every attempted, unpaid charge, including the ones the package refuses to reoffer (a bounced last direct debit on an ended subscription, a payment in flight, a failed first payment). Ask each elementnotPayableReason():nullmeans it is payable too, anEnums\NotPayableReasoncase says why it is not.- Both lists include an unpaid chargeback follow-up from the moment it
exists: it is never "attempted" by the scheduler, and it stays payable when
the subscription has ended. One that wallet credit covered in full is
settled at creation and appears in neither. One whose chargeback the bank
reversed is withdrawn (soft-deleted) and appears in neither; while a payment
of it is under way it stays in
outstandingCharges()withNotPayableReason::ChargebackReversed.
Two figures for money received, on RecurringCharge and SinglePayment:
$payment->receivedAmount()— the one "cash received" figure:amountminus standing chargebacks minus completed refunds, as a decimal string;"0.00"unpaid;nullwhen an entry cannot be read. Use it for money received:paid_onstays set on a charged-back payment, and a paid follow-up is a second paid charge for the same period. A double payment given back as credit (dunning.double_paid = credit) is not subtracted: it is still with you as a wallet balance and lowers the later charge it is spent on, so in the month of such a return this is cash, not invoiced revenue (the credit note has that). The bundled tenant dashboard and the Filament companion count with it. Loadamount,currency,paid_onand both details columns when you select columns.$payment->hasStandingChargeback()— whether a chargeback still stands. Ask it for paid periods (aspaidThrough()does), not for money received. See Recurring charges.
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.
- It returns a
{scanned, pruned, skipped}report whoseskippedkeys are the backed values ofEnums\OrphanBlocker— the complete reason domain, and contract. Branch on the enum, never on hand-typed strings; the reasons are listed in Commands. orphanBlocker($billable, $olderThanDays): ?OrphanBlockeranswers why a single registration is NOT prunable (null= it is; only authoritative under the prune's own row lock). It throwsTenantContextExceptionwhen your context hook itself fails (transient) — catch it and treat it as a skip. Three cases (AlreadyGone,TenantContextUnavailable,Error) only ever appear in the loop's report, never fromorphanBlocker().
Every guard fails closed:
- paid history (a successful €0,01 verification included), invoices or credit
block forever — that route is
anonymize(); - unresolved attempts, in-flight mandate setups and a running reminder series block until resolved;
- a soft-deleted tenant or owner is restorable and therefore NOT gone;
- an unanswerable owner question (tenant context down, owner database unreachable) skips, never deletes.
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.
- It returns a
{scanned, repaired, confirmed, raced, skipped, unresolved}report whoseskippedkeys are the backed values ofEnums\ValuedFromBlocker(the complete reason domain, contract; branch on the enum). reconstruct($line)answers about a single line with aSupport\ValuedFromReconstruction: askisResolved(), then read either->valuedFrom(aCarbonImmutable) or->blocker.
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:
- A line whose window provably starts at
starts_onis countedconfirmedand deliberately not rewritten; the report, not the column, tells the twonulls apart. - Open lines only (an ended line cannot refund again); no provider calls, no owner or tenant models, idempotent.
- Safe to run while collections are going out. The conditional write
carries every fact the answer rested on:
valued_fromstill empty,billed_throughunchanged (the walk starts there) andends_onstill null. Whatever moved, the concurrent state wins and the line is countedraced— neither repaired nor failed, and still repairable on a later run when it was the position that moved; the service log names which column it was. - The repair and its model-log line are one transaction, so a failing audit
write rolls the repair back and reports that line as
error.
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:
- be paid, still flagged, and have an
attention_messagethat still opens withRecurringCharge::MANDATE_REGISTRATION_FAILED_MARKER— the marker is the only thing telling this failure apart from a charge a customer repaired by hand with iDEAL, and registering a mandate for that one would mint a SEPA direct debit nobody signed; - carry a provider payment id and not be superseded;
- be either a mandate-setup charge, or a subscription charge whose subscription is still running (not deactivated) and still has no mandate.
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 aThrowable— 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;falsealone 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.
Support\DisconnectImpactis whatdisconnectImpact(),requestDisconnect()anddisconnectNow()return:connected,draining,disconnected(this call removed the connection), what still awaits its outcome (recurringCharges,singlePayments,openPayments(),oldestSince) and a drain'srequestedAt/deadline.toArray()gives a JSON answer.Support\DisconnectFinalizeResultcounts one finalize pass:checked,drained,deadlineReached,flagged,waiting,skipped,errors.- The events in
Events\Connect:MollieDisconnectRequestedEvent(a drain started;$openPayments),MollieDisconnectCancelledEvent(a drain called off) andMollieDisconnectedEvent(a connection removed;$drained, and$outcomeUnknown— the payments still under way, now flagged).
Settlements
The provider's payouts, mirrored by larsasub:sync-settlements — see
Settlements.
Service\SettlementService::findByReference($reference, $tenant)answers "which payout is this bank-statement line, and which payments were in it", scoped to one account (nullbeing the platform's own).syncTenant()/syncPlatform()run a pass on demand.Models\Settlementcarries the bank-statementreference, the paid-outamount, the provider'speriodsbreakdown and its payments (recurringCharges(),singlePayments(),payments()). Every payment carriessettlement_idandsettlement().Support\SettlementSyncResultis what one sync pass over one account returns: counters,skippedReason(anEnums\SettlementSyncOutcome),errorandsynced(). The outcome's backed values are also whatsettlements_sync_errorstores on the connected account; the operator table is in UPGRADE.Events\Settlement\*—SettlementRecordedEventandSettlementStatusChangedEvent.
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.
$owner->dunningCases(),Billable::dunningCases(),RecurringCharge::dunningCase();- the scopes
DunningCase::open()/unresolved(); - the candidate selection:
RecurringCharge::dunningCollectionFailed($since)/dunningChargeback($since)and their per-charge twindunningOrigin($since)($sinceaCarbonInterfaceornull). A candidate gets a case only whennotPayableReason()is null as well — count the candidates alone and you count too many.
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:
- Once per chargeback id, guaranteed by the provider's own monotonicity: a reversal timestamp never goes back to absent.
- A chargeback that is already reversed on first sight (late refresh,
on-demand fetch, missed earlier webhook) delivers both hooks, chargeback
first, reversal immediately after. A host that nets the pair lands on zero
either way; one that only implements
onPaymentChargebackkeeps exactly the behaviour it had. - The payment itself is untouched — no status change, no
payableflip. The original payment is simply paid again.
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()andcreateSinglePayment()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()andretryFailedFirstPayment(), which execute at the provider after their own commit, and forRefundService::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:
-
Console\*— command implementations (the command names are public) -
Database\Seeders\* -
Http\Controllers\*,Http\Requests\*,Http\Resources\*— including the dashboard JSON payloads. Treat them as the bundled UI's private wiring. -
Jobs\*— the package's own queued jobs, including the one that delivers notifications. Their names, constructor arguments and payload shape can change in any release; if you have them queued during an upgrade, drain the queue first, as for any other in-flight payload. -
Listeners\*— wire your own listeners to the public events instead -
Policies\*— configure authorization throughlarsasub.policies -
Providers\*— service providers -
Testing\NotificationFixtures— in-memory fixtures for the package's own test tooling -
Support\*other than the classes named in the table above — among themSupport\LicenseGate,Support\ConfigValidator,Support\ContentLocales,Support\NotificationToggle(how a notification toggle value is read),Support\VatSnapshotandSupport\ChargeBreakdownEntry -
Vat\ViesVatNumberValidator(the bundled VIES client — replace it through the contract, not by extending it),Support\VatNumber(the normalizationvat_check_numberis compared on) andSupport\VatValidationConfig(the reader ofinvoicing.vat_validation.*) -
Enums\BreakdownEntryType,Enums\NotificationFunnel,Enums\NotificationDeliveryStatus— these name internal machinery (the charge-composition snapshot, a code path, the state of a delivery-log row) -
Enums\FreePlanMandateandEnums\DoublePaidAction— the classes are internal, while their backed values (activate,mandate,refuse; andrefund,credit,signal) are contract, because they are what you write inlarsasub.subscription.free_plan_mandateandlarsasub.dunning.double_paid. Write the string, notFreePlanMandate::Refuse->value. -
Enums\AdminAudience— the same shape: the class is internal (which admin a URL is meant for), while its backed values (platform,tenant) are contract, because they arrive in the mail layout as$adminAudienceand a published layout branches on the string. Compare the string, not the case. -
Every enum not named here is public; see the table above.
-
PaymentProviders\ProviderFailure, andPaymentProviders\Mollie\*other than the models,IsMollieBillableModeland the three access classes named above — in particularMollieServiceitself.PaymentProviderFactory::make()types its return as the public contract, and that contract is the promise; the implementation behind it may change in any release. -
PaymentProviders\Mollie\Traits\HandlesMollieWebhooks— webhook resolution and mandate lookup shared by the package's own Mollie method controllers. Every method on it isprotectedand every caller sits in the internalPaymentProviders\Mollie\Httpnamespace; registering your own Mollie webhook route is not an extension point (the provider seam isPaymentProviders\Contracts\*). Its method names therefore move with those controllers. -
Helpers\*other thanBillableResolver,CountryHelpersand the fiveConfigHelpersparsers listed above — in particularGeneralHelpers(save the name ofdashboardAssetUrl(), above) and the rest ofConfigHelpers, which are the package's own plumbing -
Helpers\AdminUrlResolver, includingresolveUsing()andbaseUrlUsing(). Stated explicitly because it does not read internal: the two registration methods look exactly like the hook for pointing a company mail's buttons at your own admin. They are for admin-panel packages (larsasub-nova, larsasub-filament), which register once at boot for the platform's panel. You configure instead — the keys surviveconfig:cache, which a registered closure does not, and they are covered by the promise above:You want Use The "go to admin" button, for a tenant's own admins notifications.tenant_admin_urlThe "view payment" / "view subscription" deep link, same audience notifications.tenant_model_urlThe "go to :appName" button under a subscriber's mail notifications.user_app_urlAll three take an absolute URL or an invokable class name; the tenant-audience chain then tries a registered resolver and finally renders no button rather than the platform's own path. See Notifications.
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:
- Public surfaces change only in a major release.
- A removal is announced one minor release ahead: the old surface keeps
working, is marked
@deprecatedwith the replacement named in the annotation, and the deprecation is listed inCHANGELOG.md. - 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.
- Internal surfaces (
@internal) are exempt from all of the above, and so are the provider contracts until a second provider ships (see Provider seam). - 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).