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

Events

Every supported lifecycle change LarsaSub makes is announced with a Laravel event. Listening to one is the supported way to extend the package without modifying it: provision a workspace on activation, push an invoice into your accounting system, tell a customer their VAT number was refused.

Three omissions are deliberate and documented per event below: a plan change fires Changed instead of Activated, a subscription deactivated before its start day gets its marker without a Started event, and the scheduler-only events never fire on a host without a running scheduler.

Listening

Events live in one sub-namespace per group: Events\BillableSubscription, Events\Subscription (the plan), Events\RecurringCharge, Events\SinglePayment, Events\ProviderPayment, Events\SubscriptionCostLine, Events\Credit, Events\Mandates, Events\Invoice, Events\Connect, Events\Terms, Events\Settlement, Events\Dunning, Events\Billable. Register a listener the usual way:

// app/Providers/EventServiceProvider.php
protected $listen = [
    \PetersDevelopment\LarsaSub\Events\BillableSubscription\BillableSubscriptionActivatedEvent::class => [
        \App\Listeners\ProvisionWorkspace::class,
    ],
];

An event's public properties are its payload, and they are listed in the Payload column of each table below. The class names and their constructor arguments are public API (PUBLIC-API). Every event uses SerializesModels, so a queued listener re-reads the models it carries when it runs.

Replacing a bundled notification

Most events have a bundled listener that sends a mail. To replace one of those notification listeners (the Send*Listener classes) rather than add to it, disable it and register your own:

'notifications' => [
    'disabled_listeners' => [
        \PetersDevelopment\LarsaSub\Listeners\BillableSubscription\SendSubscriptionActivatedToUserListener::class,
    ],
],

The key does nothing for any other listener — the package warns at boot and in larsasub:doctor when it names one. The automatic settlement of a double-paid chargeback follow-up, for instance, is switched off with dunning.double_paid = signal, not here. To silence a mail rather than replace it, use its toggle; see Notifications.

Subscription events

Event Payload Dispatched when
BillableSubscriptionActivatedEvent billableSubscription, recurringCharge (nullable), source (?Enums\ActivationSource) Every activation except a plan change: first payment succeeded, free plan activated at creation, deferred-mandate creation, or a subscription you create with activated: true (an import; source is null unless you pass one). source is also stored in activation_source; recurringCharge is non-null only when a charge triggered the activation. Dispatched after the activating transaction commits
BillableSubscriptionChangedEvent previousSubscription, newSubscription, creditAmount (float) The plan changed. The change-created subscription stores activation_source = change and fires no Activated event. creditAmount is what the change added to the wallet: the unused part of the old plan, prorated, plus any credit the old subscription still carried
BillableSubscriptionStartedEvent billableSubscription, source (?Enums\ActivationSource) The upcoming→active clock transition: once, when an activated subscription's future start date arrives (marker started_processed_at). From larsasub:detect-started-subscriptions; at most once, and it can arrive a day late
BillableSubscriptionCancelledEvent billableSubscription, cancellation (array) The subscriber cancelled. cancellation is the array cancelSubscription() returns (end_date, remaining_charges, …); it holds ended_before_start: true when an upcoming subscription was cancelled
BillableSubscriptionEndedEvent billableSubscription The end date was reached — or an operator used terminateSubscription()
BillableSubscriptionRenewalAnnouncedEvent billableSubscription, announcement (array: end_date, notice_free_deadline, cancellation_notice_period, renewal_cancellation_notice_period) A fixed term with automatic renewal is two weeks from its notice-free cancellation deadline — the last day it can be cancelled before it renews
BillableSubscriptionRenewedEvent billableSubscription, renewal (array: previous_end_date, previous_duration, new_duration, new_cancellation_notice_period) Automatic renewal converted the fixed term to a continuous subscription, in place, with the renewal notice period
SubscriptionVatRateChangedEvent (Events\Subscription) plan (Subscription), previousRate, newRate (?float, null = the config rate), changedAt (?CarbonImmutable) A plan's own vat_rate changed through the model, after the commit. Every running subscription without a rate of its own follows it from its next charge. Not dispatched for a query-builder update or a change of invoicing.vat_rate. The bundled mail is notifications.user.subscription_vat_rate_changed

The invariant to build on: a new subscription lifecycle begins with exactly one of BillableSubscriptionActivatedEvent or BillableSubscriptionChangedEvent. Listen to both if you provision on activation.

BillableSubscriptionStartedEvent has two deliberate silences. It never fires for subscriptions that were never upcoming — Activated already covered that moment — and never for subscriptions deactivated before their start day (CanceledBeforeStart, terminate-on-upcoming); those get the marker without an event. For a short fixed term it can arrive after the Ended event.

Cancelling an upcoming subscription dispatches the Cancelled event and deliberately not the Ended one: nothing ended that never ran, so grace and dunning listeners on the ended event stay quiet.

Charge events

A RecurringCharge is any charge the package collects: a subscription period, a standalone charge, a chargeback follow-up, or the €0,01 mandate verification. Every event here carries recurringCharge and billableSubscription (null for a charge without a subscription).

Event Payload (besides the two above) Dispatched when
RecurringChargeNotifiedEvent — The advance notice for a charge flagged notify is due (larsasub:notify-charges); the bundled announcement mail listens to it
RecurringChargeChargedEvent — The charge was submitted to the provider
RecurringChargePaidEvent firstPayment (bool) Payment confirmed. firstPayment marks the collection that activated the subscription. The invoice (if any) already exists by then. Also fired when the package settles a charge itself — wallet credit covers it in full, or nothing is left to collect when it is executed — which fires no RecurringChargeChargedEvent. A €0 first charge is marked paid at creation and fires neither
RecurringChargeFailedEvent firstPayment (bool) The provider rejected it
RecurringChargeCanceledEvent firstPayment (bool) The payment was cancelled
RecurringChargeExpiredEvent firstPayment (bool) The payment link expired
RecurringChargeChargebackEvent chargeback (Support\PaymentChargeback) Per chargeback, once per id
RecurringChargeChargebackReversedEvent chargeback (Support\PaymentChargeback, with reversedAt filled) Per chargeback the provider reversed, once per id
RecurringChargeRefundedEvent refund (Support\PaymentRefund) Per completed refund
RecurringChargeFollowUpCreatedEvent original, chargeback; recurringCharge is the follow-up A chargeback became a follow-up charge. After commit, once per chargeback
RecurringChargeFollowUpWithdrawnEvent original (nullable), chargeback The bank reversed the chargeback before the follow-up was paid; the follow-up is soft-deleted. After commit, once per follow-up. Fires instead of the failed/expired/canceled event when a payment under way ends that way after the reversal
RecurringChargeFollowUpDoublePaidEvent original (nullable), chargeback, amount, settleOn, action The bank reversed the chargeback after the follow-up was paid. See below. After commit, once per follow-up

A mandate verification fires RecurringChargePaidEvent like any charge — check $event->recurringCharge->mandate_setup — but when it fails, expires or is cancelled it fires MandateSetupFailedEvent instead of the failed/expired/canceled events.

Double payment. On RecurringChargeFollowUpDoublePaidEvent, amount is what was paid twice when it was found (decimal string), settleOn is where the money goes back (original or follow_up), and action is the dunning.double_paid value at that moment (null: the listener reads the config). Handle the nulls: amount is null when the follow-up's history is unreadable; settleOn is null then, when amount is 0.00 (nothing left to give back), and when the original's refund history is unreadable. The package's own listener settles it per action — refund, credit, or signal (flag the follow-up for an operator; the default) — and never gives back more than is still paid twice when it runs.

RecurringChargeChargebackReversedEvent leaves the charge's status and payable flag untouched: the original payment is simply paid again. A chargeback that is already reversed on first sight fires the chargeback event first and this one straight after. Its follow-up, when there is one, is withdrawn, marked or treated as paid twice before this event is dispatched, so a listener on it already sees that state; the two follow-up events themselves are delivered after commit, so they arrive after this one.

Order within one chargeback delivery. When wallet credit covers a new follow-up in full, it is settled on the spot, so RecurringChargePaidEvent for the follow-up fires before RecurringChargeChargebackEvent for the original. A listener that extends access by a period on every paid event sees the same period paid twice: skip a charge whose isChargebackFollowUp() is true, or one whose hasStandingChargeback() is true.

The refund and chargeback value objects survive SerializesModels, so a queued listener sees the refund the event was fired for — not the snapshot at execution time. Key your idempotency on $refund->id / $chargeback->id, and check isBookable() before booking an amount.

Single payment events

Every event here carries singlePayment.

Event Payload (besides singlePayment) Dispatched when
SinglePaymentPaidEvent — Payment confirmed
SinglePaymentFailedEvent — The provider rejected it
SinglePaymentCanceledEvent — Cancelled
SinglePaymentExpiredEvent — Expired
SinglePaymentChargebackEvent chargeback (Support\PaymentChargeback) Per chargeback
SinglePaymentChargebackReversedEvent chargeback (Support\PaymentChargeback) Per reversed chargeback
SinglePaymentRefundedEvent refund (Support\PaymentRefund) Per completed refund

Refunds and chargebacks are delivered per occurrence, keyed on the provider's id — never book the payment total. Attached IsPayable entities get their hooks called alongside these — see Single payments.

Provider payment events

Shared across RecurringCharge and SinglePayment: payment is either.

Event Payload Dispatched when
RefundRequestedEvent payment, request (Support\RefundRequest) RefundService::refund() asked the provider for a refund, or adopted one that already carried the key — after commit, at least once. Not the completion: that is RecurringChargeRefundedEvent / SinglePaymentRefundedEvent
PaymentNeedsAttentionEvent payment, message (string) A charge or single payment was flagged needs_attention for an operator (setNeedsAttention()), whatever the reason — or Mollie reported on one after the tenant's Connect account was disconnected (CONNECT_DISCONNECTED_MARKER; once per record, never per payment flagged by the disconnect itself)

PaymentNeedsAttentionEvent is the alarm for "no usable mandate", "tenant context unavailable" and provider rejections at execution time — the money that will not collect itself. The same event fires for every other flag: a claim the reconciliation parked, a failed mandate registration, a double payment under dunning.double_paid = signal, a credit note that could not be created. $event->message says which. Its bundled company mail is notifications.company.payment_needs_attention.

Cost line events

Event Payload Dispatched when
SubscriptionCostChangedEvent billableSubscription, changes (Support\CostLineChangeSet) Once per act: one reconcileLines() call, one batch() scope, or one standalone mutation. changes holds every change plus the settled billing-period total
SubscriptionCostLineAddedEvent billableSubscription, costLine, monthlyTotal (float) SubscriptionCostService::addLine(), and per added line inside a reconcile
SubscriptionCostLineChangedEvent billableSubscription, costLine, monthlyTotal (float) SubscriptionCostService::changeLineAmount(), and per repriced line inside a reconcile
SubscriptionCostLineEndedEvent billableSubscription, costLine, monthlyTotal (float) SubscriptionCostService::endLine(), and per removed line inside a reconcile

Notify from SubscriptionCostChangedEvent, not from the per-line three. The per-line events fire while a set is still being assembled — reconciling three modules fires three of them — and each carries the billing-period total at that moment, so two of those three name an amount the subscriber will never be charged. They exist for audit and for hosts that track individual mutations. The bundled cost-change mail listens to the batched event for the same reason.

public function handle(SubscriptionCostChangedEvent $event): void
{
    // Skip sets that are a subscription's opening composition, or were made
    // inside SubscriptionCostService::silently() — see Cost lines.
    if (! $event->changes->shouldAnnounce()) {
        return;
    }

    foreach ($event->changes->announceable() as $change) {
        // $change->type (CostLineChangeType), ->description, ->monthlyAmount,
        // ->previousMonthlyAmount, ->effectiveOn
    }

    $total = $event->changes->monthlyTotal;   // as of ->effectiveOn
}

monthlyAmount is listed, monthlyTotal is debited. Three things move them apart: cost lines, VAT (invoicing.prices_include_vat = false adds it to every listed amount; a zero-rated customer on inclusive prices has it stripped out), and an introduction period, where the charge bills the introduction price.

  • ->vatNotice says which VAT case applies: an Enums\CollectionNotice (gross, reverse-charge, net) or null — and null is what a default install always yields, so handle it explicitly; a match without a null arm throws on the first cost-line change in production.
  • ->vatRate carries the rate, for the gross case only.
  • ->introMonthlyTotal is what is collected while an introduction period runs; monthlyTotal is then the amount that follows it.
  • Both totals are per BILLING PERIOD, not per month: on a yearly plan ->monthlyTotal is the yearly debit. The names are frozen wire format (the set rides a queued payload) — read ->intervalMonths (1 = monthly) and caption the amount with that, never with the word "month". The per-change ->monthlyAmount fields are unaffected: a cost line's amount stays monthly whatever the plan's interval is.

All of these are frozen with the total rather than resolved when you render — a customer editing their country between the mutation and your queued job would otherwise get a label describing a number it does not describe. If you print both figures, say which is which, as the bundled notice does.

Credit events

Event Payload Dispatched when
CreditAddedEvent billableCredit, transaction Credit was added (CreditService::addCredit(), and every path that returns credit)
CreditDeductedEvent billableCredit, transaction Credit was deducted — applied to a charge, or taken off through CreditService::deductCredit()
CreditExpiringEvent billableCredit, transaction An addition with an unspent remainder expires within scheduling.notify_credit_expiration_days_before days (larsasub:notify-expiring-credits); once per addition
CreditExpiredEvent billableCredit, originalTransaction, expiredAmount (float) Credit expired (larsasub:process-expired-credits)

Mandate events

mandate and replacement are typed Models\BaseModel (provider-neutral): narrow with instanceof ProviderMandateModel before reading mandate fields.

Event Payload Dispatched when
MandateMarkedUsableEvent mandate A mandate became usable (setup confirmed) — also the trigger for the user's "payment method added" mail
MandateMarkedUnusableEvent mandate, partOfRemoval (bool) A mandate was revoked or invalidated — by the provider (the weekly validation, a revocation at the bank) or as the in-transaction step of every removeMandate(). partOfRemoval tells the two apart (declaration-defaulted false for payloads queued before it existed): a genuine invalidation triggers the user's proactive "payment method no longer usable" mail; the removal step is skipped there because MandateRemovedEvent follows and announces it
MandateRemovedEvent mandate (soft-deleted), replacement (nullable) A mandate was removed through MandateService::removeMandate(): reassignment committed, provider revoke attempted, local row soft-deleted. replacement is the mandate future collections were moved to (null when nothing was using it). Dispatched after the whole operation, through the after-commit hook — a host wrapping the call in its own transaction never sees it before the rollback window closed. A provider-side invalidation alone never fires this event
MandateSetupFailedEvent charge, providerStatus (failed, expired or canceled) The €0,01 setup checkout ended in a terminal provider state
MandateRegistrationFailedEvent charge, billable, billableSubscription, exceptionClass, exceptionMessage A first payment was paid and the mandate registration behind it threw: money in, subscription active, and nothing to collect the next period with. billableSubscription is null for a €0,01 setup charge. The exception itself deliberately does not travel, so a ShouldQueue listener cannot break on the payload. Every property is nullable: a late webhook can land on a hard-deleted billable

MandateRegistrationFailedEvent does not replace anything — the generic PaymentNeedsAttentionEvent still fires for the same charge (setNeedsAttention() dispatches it), together with its company mail. This event exists because that one fires for every attention reason there is, so reacting specifically to a missing mandate would otherwise mean string-matching attention_message. Its own company notification (notifications.company.mandate_registration_failed, default on) names the consequence rather than the symptom; hosts that want a single mail turn the generic key off.

Paid is a condition, not a description. The mandate step also runs for a payment that is not terminal yet — a card under review, a PayPal authorization, a bank transfer — and it can fail there on details the provider has not filled in. Nothing has been collected in that case, so this event does not fire and the generic PaymentNeedsAttentionEvent (which always does) is the signal for it. If the payment later succeeds and the mandate step fails again, that is when this event arrives.

MandateSetupFailedEvent replaces the generic charge failed/expired/canceled events for setup charges, so an abandoned verification mails nobody. Its pull-side counterpart is $owner->mandateSetupStatus() — see Payment methods and mandates.

Invoice events

Event Payload Dispatched when
InvoiceGeneratedEvent invoice An invoice was created
CreditNoteGeneratedEvent creditNote A credit note was created

Use these to push documents into an accounting system. Invoicing is off by default (invoicing.enabled); with it off the package generates no documents on its own, so neither event fires. See Invoicing.

Connect and terms events

The Connect events carry account, the tenant's MollieConnectedAccount.

Event Payload Dispatched when
MollieConnectionLostEvent account, reason (string), outcomeUnknown (nullable int) A tenant's OAuth connection died on an auth rejection — also how a revocation in Mollie's own dashboard arrives. outcomeUnknown is how many payments still under way it flagged (null on a payload queued before the property existed)
MollieDisconnectRequestedEvent account, openPayments (int) A tenant asked to disconnect and the account now drains, with openPayments still under way. When nothing was under way, MollieDisconnectedEvent follows at once
MollieDisconnectCancelledEvent account A draining disconnect was called off — cancelDisconnect(), or the tenant connecting the same organization again
MollieDisconnectedEvent account, drained (bool), outcomeUnknown (int) The connection was removed on purpose: drained (drained true), at the drain deadline, or disconnectNow(). outcomeUnknown payments were still under way and are flagged
MollieProfileChangedEvent account, previousProfileId, profileId A tenant selected another Mollie website profile — every payment from here on carries its brand and its enabled payment methods. Not fired for the package's automatic pick on a fresh connect
TermsAcceptedEvent termsAcceptance An acceptance was recorded (TermsAcceptanceService::record()), one event per record: the bundled subscribe flow records the terms and, with terms.withdrawal_consent on, the withdrawal consent as a second record (context tells them apart). Your own record() call fires it too

A drain finishes in the last pass of larsasub:execute-charges, so the MollieDisconnectedEvent of a drained or deadline-reached disconnect needs the scheduler — or your own MollieConnectService::finalizeDisconnects() call on a headless host without one. See Disconnecting a tenant.

Settlement events

Event Payload Dispatched when
SettlementRecordedEvent settlement A Mollie payout became known locally for the first time, whatever its status
SettlementStatusChangedEvent settlement, previousStatus A known payout changed status. Book the payout on paidout

Both are dispatched by the settlement sync — the scheduled larsasub:sync-settlements or your own SettlementService::syncTenant() / syncPlatform() call — after the payout's payments were attached, so $event->settlement->payments() is complete when you read it. A pass that fails between the row and its payments announces nothing; the next run redoes it and announces then. There is no event per attached payment.

A queued listener re-reads the settlement when it runs; if the row was deleted in between, set public $deleteWhenMissingModels = true; on it, or the job fails before handle(). See Settlements.

Billable events

Event Payload Dispatched when
VatNumberCheckedEvent billable, status, previous (both Enums\VatNumberStatus; previous usually pending) The VIES check of a registration's VAT number reached another status

It fires whenever a check runs — with invoicing.vat_validation.enabled on, but also for the --unchecked backfill and checkNow(), which run while it is off — after the write commits, and only on a real change. A listener that mails customers therefore fires during a backfill too. A re-check that confirms valid again, or gets no answer and keeps it, is not announced.

The package mails nobody about a check. Listen here to tell a customer their number was refused, or to keep the consultation number ($event->billable->vat_check_reference) in your own administration. A queued listener re-reads the billable when it runs, which may already carry a newer check. See Validating VAT numbers.

Dunning events

The reminder series. Every one carries case (a DunningCase) and recurringCharge.

Event Payload (besides case, recurringCharge) Dispatched when
DunningStartedEvent — A series was opened for an unpaid charge
DunningReminderDueEvent stepIndex, final (the last reminder of the schedule) A step reminds the customer — a notify step, or an escalate step with 'notify' => true
DunningEscalatedEvent stepIndex An escalate step was taken. What happens now is yours to decide — the package never cuts off access
DunningExhaustedEvent stepIndex The last step was taken and the charge is still unpaid; fires with that step's other events
DunningResolvedEvent status, closedReason The series ended: status is resolved_paid, or resolved_closed with closedReason

stepIndex counts from 0 into $case->schedule — the schedule the case kept when it opened, not the current config.

At least once. A run dispatches after its commit, and a later run sends again what did not go out — so the same event can reach you twice. The one exception: a step whose series resolved before its events went out is dropped, and never sent late. So:

A listener that throws is logged and the run carries on: what committed stays, the run counts dispatch_failed and exits with a failure, and the event is sent again by a run at least 15 minutes later — while the series is on. A Resolved from a payment while the series is switched off waits until it is switched on again.

For a step's events (sent again together, with their siblings of that step) the series takes no next step until they went out; a Started that did not go out is retried every run but holds nothing back. close() never throws on your listener's account. Prefer ShouldQueue listeners: a synchronous one runs inside the scheduled command — and, for a Resolved after a payment, inside the paid webhook.

To remind customers yourself (sms, in-app), listen to DunningReminderDueEvent — and switch the package's own reminder off (notifications.user.dunning_reminder, or SendDunningReminderToUserListener in notifications.disabled_listeners), or the customer gets both.

Scheduler-only events

These are dispatched exclusively by the scheduled commands. On a host without a running scheduler, or with scheduling.enabled = false, they never fire — and neither do the mails that listen to them:

Two more depend on a scheduled pass without being exclusive to it: the MollieDisconnectedEvent of a drain (above) and the settlement events, which your own sync call fires too.

If your app provisions or de-provisions on one of these, the scheduler is not optional. See Scheduled commands.

Adding a property to a queued event

Relevant if you fork or extend the package, and the reason some LarsaSub events carry nullable properties that look like they could be required.

A property added to an existing queueable event or notification gets its default on the declaration, not only on the constructor parameter:

public ?ActivationSource $source = null;   // ✅

unserialize() never runs the constructor, so a payload queued before the release leaves the property uninitialized and the first read throws an Error. ?-> does not help — uninitialized is not null.

And such a property must be nullable with a default, never a required non-nullable type: a non-nullable one has no upgrade path except draining the queue before deploying, which is a step the host has to get right.