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

Subscriptions

This page covers the plans you sell, the subscriptions your customers hold, and every step in between: subscribing, the first payment, changing plan, cancelling, renewal and features. Read it before you build a subscribe flow of your own, or when you need to know exactly what a lifecycle call does.

Plans and instances

Three models carry a subscription, each one level more concrete:

Subscription              the plan template an operator or tenant defines
    ↓
BillableSubscription      one subscriber's instance of that plan
    ↓
RecurringCharge           one payment in that instance's billing history

A Subscription is a catalog entry. A BillableSubscription is a contract: it copies the plan's price, billing interval, duration, introduction, notice periods, name and description at creation time, so later edits to the plan never change what a running subscriber pays or how often. The VAT rate is the exception: it is the law in force when a period is paid, not a contract term, so every charge reads it from the plan as it is then — see Per-subscription VAT rate.

The Subscription plan

A plan is a Models\Subscription row in larsasub_subscriptions, defined by an operator (Nova, Filament, a seeder, your own code) or by a tenant through the bundled tenant dashboard. Every write goes through the model's validation, whichever tool made it.

$plan = Subscription::create([
    'name' => 'Pro',
    'active' => true,
    'duration' => 'continuous',
    'billing_interval_months' => 1,
    'is_charged' => true,
    'price_amount' => 29.00,
    'introduction_duration' => 'none',
    'cancellation_notice_period' => '1 month',
    'renewal_cancellation_notice_period' => 'none',
    'automatic_renewal' => false,
]);

Plan properties

Property Description
name, description, content Customer-facing copy (translatable — see Multilingual plan content)
internal_note Not shown to customers
active Whether the plan can be subscribed to (isEnabled() / isDisabled())
start_date, end_date The plan's own availability window
duration The contract term: continuous, or a fixed term written N month (1 month, 12 month, 24 month, …). Not how often it bills
billing_interval_months Months per billing period, a whole number 1–12: 1 (monthly, the default), 3 (quarterly), 6 (half-yearly), 12 (yearly). The bundled forms offer those four
is_charged Whether the plan charges at all
price_amount, price_currency Regular price per billing period — a €950 yearly plan stores 950 with interval 12. The currency defaults to currency.default; keep it there, since nothing checks it and credit only pays a charge in that currency (Currency)
introduction_duration, introduction_price_amount Optional discounted introduction (none when there is none) — see Introduction pricing
cancellation_notice_period Notice before a cancellation takes effect (none or N month)
automatic_renewal Whether a fixed-term plan renews by default
renewal_cancellation_notice_period The notice period a renewed subscription runs on — see Renewal
minimum_days_before_change Per-plan override of subscription.minimum_days_before_change, counted from activation
vat_rate Per-plan VAT rate; null = config rate, 0 = explicit 0%. A change reaches every running subscription without a rate of its own from its next charge, and mails the subscribers whose amount it changes
feature_list, features Feature codes and values — see Features
order_nr Display order
tenant_id, tenant_type null = a global plan; set = a tenant's own plan
fee_type, fee_value Per-plan application-fee override (Connect); getEffectiveFee() resolves it against the config default

Validation rules

The model validates on both create and update, so Nova, Filament, the tenant dashboard, seeders and your own code are held to the same rules. A violation throws a LarsaSubException with a translated message.

Rule Why
billing_interval_months is a whole number from 1 to 12 A typo such as 30 would bill years ahead
A fixed duration is a multiple of the billing interval Every charge bills one whole period
A non-none introduction_duration is a multiple of the billing interval The introduction price replaces a whole period's price
With an introduction, introduction_price_amount is at least 0.01 See Introduction pricing
A 1 month plan has both notice periods none and no automatic renewal Nothing to give notice on or renew
A continuous plan has no automatic renewal and a renewal notice of none It never ends, so it never renews
is_charged = false requires a price and introduction price of 0
is_charged = true requires a price other than 0 A free plan is is_charged = false with price_amount = 0. A negative price is not refused here; its first charge throws instead

The bundled tenant dashboard also caps notice periods at subscription.max_notice_period_months.

Change targets

A plan declares which other plans a subscriber may switch to:

$plan->changeTargets();                    // the raw relation
$plan->availableChangeTargets($tenant);    // active targets, optionally tenant-filtered

Use availableChangeTargets() everywhere you offer a choice. Nothing removes target rows when a plan is retired, so the raw relation can still name withdrawn pricing. changeSubscription() validates against the available set for exactly that reason.

Billing intervals

A plan collects once per billing_interval_months months. Each charge bills one whole period on the anniversary boundary: a quarterly plan started 12 March collects on 12 June, 12 September, and so on. The interval is copied onto the subscriber's instance, so editing the plan never changes a running subscription.

Introduction pricing

An introduction replaces the regular price for the first introduction_duration months. It counts months covered by paid charges: a 3 month introduction is three charges on a monthly plan and one on a quarterly plan, and a 12 month introduction on a yearly plan discounts one charge. A failed period does not use up a discounted slot.

$subscription->isInIntroductionPeriod();
$subscription->remainingIntroductionPayments();   // charges, not months
$subscription->currentEffectivePrice();           // the introduction price or the regular one

An introduction price below €0,01 is not a supported plan. With an introduction set, the price has to be at least 0.01 — zero, negative and sub-cent values are refused. A €0 introduction on a priced plan would settle the first charge locally without a checkout, so the customer never sets up a mandate and the second period has nothing to collect with.

The rule fires whenever either introduction field is written. An existing plan that already carries a €0 introduction is not migrated: its subscribers keep the deal they made, and the plan stays saveable (you can still switch it off). A plan saved without an introduction is normalised to none and 0.00.

What you want instead of a €0 introduction is either a real introduction price, or a genuinely free plan.

Free plans

A free plan (is_charged = false, price_amount = 0) activates at creation, and its first charge is €0 and settles locally, without a checkout. Because no checkout means no payment mandate, subscription.free_plan_mandate decides what happens when the subscriber has nothing to collect with:

Value What happens
activate (default) Activate without a mandate
mandate Activate, then send the customer into the €0,01 mandate verification — on the provider's createSubscription() only
refuse Refuse the subscription with a BillableSubscriptionServiceException before anything is created

A subscriber who already holds a usable mandate is left alone by all three, and the two mandate-backed entry points are never refused. Read the full description before you pick mandate or refuse.

The BillableSubscription instance

A BillableSubscription is one subscriber's contract, in larsasub_billable_subscriptions. It references the Billable registration, never your owner model directly — see Billable models.

Instance properties

Property Description
subscription_id The plan it was created from
billable_id The Billable registration
payment_method_id Payment method used
provider_mandate_type, provider_mandate_id The mandate its charges collect with (providerMandate)
activated_at When it became active
activation_source Enums\ActivationSource: first_payment, free, deferred_mandate, change — null on a host-forced activation without a source
deactivated_at When it was deactivated
deactivation_reason Enums\DeactivationReason — the machine-readable reason
activated_status Human-readable status text
start_date, end_date end_date null = continuous
first_charge_on The first day it may be collected on; null = from start_date. See Covered periods and imports
duration, billing_interval_months Copied from the plan
is_charged, price_amount, price_currency Copied from the plan
introduction_duration, introduction_price_amount Copied from the plan
cancellation_notice_period, renewal_cancellation_notice_period Copied from the plan
automatic_renewal Copied from the plan; cleared by a cancellation and by a renewal
name, description Copied from the plan, with every translation
vat_rate An explicit override for this subscription only, never a copy; null = follow the plan, then the config. getEffectiveVatRate() resolves it
feature_overrides Per-subscriber feature overrides
canceled_at Set on cancellation; the subscription stays active until end_date
credit_amount Credit carried on this subscription; moved to the credit balance on a plan change
renewal_announced_at, started_processed_at Scheduler markers

activated_status is display and audit text, and is never queried. Key your own logic on deactivation_reason and activation_source. The first-payment failures are one family: $reason->isFirstPaymentFailure() answers for a single value, and DeactivationReason::firstPaymentFailureValues() gives the stored values for a whereIn().

Lifecycle state

state() returns an Enums\SubscriptionState, computed from the timestamps and dates. It is deliberately not a column.

State Meaning
Pending Never activated: awaiting, or failed, its first payment
Upcoming Activated, not deactivated, start date still in the future
Active Activated, not deactivated, inside its date window
Ended Activated and never deactivated, but past its end date
Deactivated Explicitly deactivated: ended, changed, terminated, cancelled before start, or failed

Status methods

$subscription->isActive();                 // activated, not deactivated, today inside start..end
$subscription->isUpcoming();               // activated, future start date
$subscription->isInactive();               // neither active nor upcoming
$subscription->isCancelled();
$subscription->isChargeable();             // is_charged, and a price above 0 or a billable cost line
$subscription->willRenew();
$subscription->willEnd();
$subscription->isInIntroductionPeriod();
$subscription->hasFailedFirstPayment();    // keys on deactivation_reason
$subscription->hasBillingHistory();        // any charge exists, of any status
$subscription->state();                    // SubscriptionState enum
$subscription->remainingIntroductionPayments();
$subscription->currentEffectivePrice();    // introduction price or regular price
$subscription->getNextBillingDate();       // 'Y-m-d', or null — see below
$subscription->paidThrough();              // last day paid for — see below
$subscription->getEffectiveVatRate();      // override, else the plan's rate now, else null (= the config rate)

The date checks work in whole calendar days: end_date is inclusive through that day, and a subscription starting today is active, not upcoming. The is_active, is_upcoming and is_inactive attributes are appended to every serialized instance.

getNextBillingDate() is the day the planner will plan the next charge, as the subscription stands now. It honours first_charge_on and a period whose collection day has passed without a charge (that one is collected today). It returns null when the planner would plan nothing: not activated, deactivated, not chargeable (a free plan), or the end date comes before the next billing day. A charge that already exists — a first payment still in checkout, a standalone charge — is not part of the answer.

paidThrough() is the supported answer to "may this customer still have access?". It returns the last day covered by the newest paid charge (a charge whose chargeback stands does not count; a reversed chargeback counts again), or the day before first_charge_on when that is later, or null when nothing was ever paid. A date in the past means coverage ran out; whether access continues during a payment problem is your decision. See Recipes.

Relations

$subscription->subscription;            // the plan
$subscription->vatPlan;                 // the same plan, also when soft-deleted — the VAT rate is read from it
$subscription->billable;                // the Billable registration
$subscription->paymentMethod;
$subscription->providerMandate;
$subscription->costLines;               // SubscriptionCostLine
$subscription->recurringCharges;
$subscription->paidRecurringCharges;
$subscription->unpaidRecurringCharges;
$subscription->plannedRecurringCharges; // unpaid, not yet executed
$subscription->pendingRecurringCharges; // executed, awaiting outcome

These relations include chargeback follow-ups: an unpaid follow-up is "planned" by these definitions (not executed, not paid) although it is a debt the customer pays, never a planned collection. Add ->notChargebackFollowUp() where you mean the series. A plan change is refused while such a follow-up is unpaid — see that section for why.

Actions

$subscription->activate($status, $charge, $source);   // all optional
$subscription->deactivate($status, $reason);          // $reason: ?DeactivationReason
$subscription->cancelFutureCharges($afterDate);       // returns the number removed
$subscription->markRenewalAnnounced();
$subscription->switchMandate($mandate);

These are the low-level building blocks the services use. activate() has no idempotency guard of its own and must run inside a LarsaSub transaction; prefer the service calls below. switchMandate() requires an active or upcoming subscription and a usable mandate of the same billable, and moves every planned charge to the new mandate with it.

Lifecycle

A subscription moves through these steps. Each section below says what the package does at that step and what you can rely on.

  1. Creation — the row exists, not yet activated (unless free).
  2. The first payment — the first charge is priced and collected.
  3. Activation — the payment succeeded; the subscription is live.
  4. Deactivation — a failure, an ending, a change or a termination.
  5. Recurring charges — one charge per billing period, on its collection day.
  6. Renewal — a fixed term renews in place, or ends.

Creation

BillableSubscriptionService::createSubscription() checks that the billable has no active or pending subscription, creates the row with activated_at = null, and activates it immediately when the plan is free. It runs in one transaction; see When creation is refused for every reason it throws.

The first payment

RecurringChargeService::generateFirstSubscriptionPayment() creates the first RecurringCharge, and the provider sends the subscriber to its checkout. A zero-amount charge is marked paid at once instead.

It prices the charge through the same calculation the recurring planner uses: the plan price (the introduction price while one runs) plus the active cost lines. The composition is frozen into amount_breakdown, and each line's billed_through moves forward in the same transaction, so period 1 is collected and invoiced in full.

Two preconditions are enforced:

Every path that abandons a first charge — the failure webhook, a retry, a checkout that could not be created, the cleanup sweep and terminating — rolls its cost-line bookkeeping back, so ending a line later never credits days nobody paid for.

Activation

activate() sets activated_at, activated_status and activation_source, and clears any stale deactivation_reason. It dispatches BillableSubscriptionActivatedEvent after the transaction commits — on every path except a plan change, where BillableSubscriptionChangedEvent is that transition.

A new subscription lifecycle begins with exactly one of those two events. Listen to both if you provision on activation. See Events.

On the webhook path, the guard against a double activation lives in RecurringCharge::onPaid(), under a row lock.

Deactivation

deactivate() sets deactivated_at, deactivation_reason and a descriptive activated_status. Reasons the package writes include a failed first payment (the retryable family), ContractPeriodCompleted when a fixed term ends, Changed on a plan change, Terminated by the admin hammer, and CanceledBeforeStart when an upcoming subscription is cancelled.

A subscription with a failed first payment can be retried through retryFailedFirstPayment() or removed through terminateFailedSubscription().

Abandoned checkouts

larsasub:cleanup-unused-billable-subscriptions (twice daily by default) deactivates never-activated subscriptions older than scheduling.cleanup_unused_billable_subscriptions_hours (default 12), with DeactivationReason::FirstPaymentAbandoned. It leaves alone:

The charges it leaves behind are made harmless in the same transaction: the cost-line bookkeeping is rolled back, and a charge that was never submitted to the provider is soft-deleted, so it can never be collected on top of a successful retry.

The subscription row itself stays until the subscriber terminates it. It is what keeps the subscriber's retry and terminate buttons reachable. Subscribing to another plan succeeds right next to it — the double-subscribe guard ignores deactivated rows — and larsasub:prune-billables --orphaned only applies to registrations whose owner no longer exists, which an abandoned checkout is not. The bundled tenant dashboards leave it out of their subscriber and charge lists.

Recurring charges

larsasub:plan-charges creates each period's charge on its collection day, never ahead: base price plus active cost lines, with the composition stored in amount_breakdown. larsasub:execute-charges submits it to the provider — with the default schedule, the same night — and webhooks resolve it. A cost line changed before the collection day is therefore part of that day's charge. See Recurring charges and Scheduling.

The planner only selects subscriptions that are active, chargeable and past any first_charge_on.

Subscribing

The supported way to subscribe a customer is the payment provider's createSubscription(). It creates the subscription, prices the first charge and returns a redirect to the checkout — the bundled subscribe flow calls exactly this, and so should a headless host:

use PetersDevelopment\LarsaSub\PaymentProviders\PaymentProviderFactory;

$provider = PaymentProviderFactory::make(config('larsasub.provider'));

return $provider->createSubscription($owner, $plan, $paymentMethod);

What it does:

If the checkout cannot be created, the subscription is deactivated (FirstPaymentCreationFailed) and soft-deleted, and a BillableSubscriptionServiceException with a translated message is thrown.

To name the amount on a checkout button before the subscription exists, ask SubscriptionCostService::quoteFirstCharge() — see Quoting the first charge.

Creating the subscription yourself

BillableSubscriptionService::createSubscription() creates the row only — no charge, no checkout. Use it for imports, or when you collect the first payment another way.

use PetersDevelopment\LarsaSub\Service\BillableSubscriptionService;

$billableSubscription = app(BillableSubscriptionService::class)->createSubscription(
    owner: $user,
    subscription: $plan,
    paymentMethod: $method,
);

Full signature:

createSubscription(
    Model $owner,                                   // the owner model, or a Billable
    Subscription $subscription,
    PaymentMethod $paymentMethod,
    bool $activated = false,
    string|CarbonImmutable|null $startDate = null,  // default today
    ?bool $isCharged = null,                        // default: the plan's own flag
    ?ActivationSource $activationSource = null,
    string|CarbonImmutable|null $paidThrough = null,
    ?Closure $beforeFirstCharge = null,
): BillableSubscription

A fixed-term end date is computed from start_date: the day before the same day of the month, duration months on. Passing activated: true on a priced plan without an activationSource stores a null source and the generic activation mail.

This method never reads subscription.before_first_charge itself; only the three first-charge entry points resolve that key. To collect the first payment afterwards, call RecurringChargeService::generateFirstSubscriptionPayment() with the preconditions above.

Covered periods and imports

paidThrough is the last day someone else already covered — the previous system on an import, or a trial. It is stored as first_charge_on (the day after), and nothing is collected before that date: the planner skips the subscription and generateFirstSubscriptionPayment() refuses. It may not lie before start_date.

start_date keeps its own meaning, so the anniversary day and the period bounds still follow the real contract. A paidThrough in the past does not hold collection back: the first charge covers the period containing today, and the backlog between start_date and that period is written off. See Migrating subscribers in.

Cost lines on the first charge

Cost lines added after the first charge is generated are caught up on the next one, which is a period late for anything sold during onboarding. The supported moment is the beforeFirstCharge seam:

$provider = PaymentProviderFactory::make(config('larsasub.provider'));

$provider->createSubscription($owner, $plan, $method, function (BillableSubscription $subscription) use ($modules) {
    app(SubscriptionCostService::class)->reconcileLines($subscription, $modules, ['module:']);
});

Signature: function (BillableSubscription $subscription, ?BillableSubscription $previous = null): void. $previous is filled on the plan-change path only. The closure is available on PaymentProviderService::createSubscription(), createSubscriptionWithMandate() and BillableSubscriptionService::changeSubscription(). Hosts on the bundled dashboards configure subscription.before_first_charge instead; an explicit closure always wins.

Call the provider entry points outside your own transaction. They end in an HTTP call to Mollie; the package commits before making it so no lock is ever held across a provider round trip, and a DB::transaction() of yours around it nests as a savepoint and takes that guarantee away. A rollback afterwards is worse than slow: the checkout exists at the provider and the charge it belongs to does not. See Wrapping a LarsaSub call in your own transaction.

When creation is refused

createSubscription() throws a BillableSubscriptionServiceException with a translated message, and writes nothing, when:

It does not ask whether the plan is on sale to this subscriber. The bundled subscribe flow only accepts an enabled plan — under Connect, one of the subscriber's tenant or a global one — and answers any other id with a 404; a headless host makes that check itself before it calls the provider's createSubscription().

In Connect mode, a tenant draining its Mollie disconnect refuses every new subscription, plan change and first-payment retry with ConnectDisconnectPendingException. That one extends PaymentProviderServiceException, not BillableSubscriptionServiceException, so catch both. See Disconnecting a tenant.

The double-subscribe check runs twice: once cheaply, and again inside the transaction under a row lock, so a double click or two concurrent requests cannot both pass it.

Subscribing with an existing mandate

Two entry points reuse a usable mandate without a checkout. They differ in when the first charge and the activation happen — pick by whether the first charge is due now or later.

createSubscriptionWithMandate (immediate) createDeferredMandateSubscription (deferred)
Lives on The payment provider (PaymentProviderService) BillableSubscriptionService
First charge Generated and submitted to the provider against the mandate during the call None — larsasub:plan-charges generates it on its collection day, from start_date
Cost lines on the first charge The beforeFirstCharge hook Added by the caller after creation, before the planner runs (no hook)
Activation When the payment succeeds (a SEPA debit takes days); immediately only for a zero amount Immediately at creation, independent of payment
start_date Today Caller-supplied, may be in the future; a past date clamps to today unless paidThrough is given
Returns RedirectResponse BillableSubscription
Use when The subscriber must pay now (re-subscribing after expiry) "Mandate now, first charge later" — honouring a remaining trial
use PetersDevelopment\LarsaSub\PaymentProviders\PaymentProviderFactory;
use PetersDevelopment\LarsaSub\Service\BillableSubscriptionService;

// Immediate — note this lives on the PaymentProviderService, not the subscription service
$provider = PaymentProviderFactory::make('mollie');
$provider->createSubscriptionWithMandate($owner, $plan, $method, $mandate);

// Deferred — activates now, bills from $trialEndsAt, makes no provider call
$subscription = app(BillableSubscriptionService::class)
    ->createDeferredMandateSubscription($owner, $plan, $method, $mandate, $trialEndsAt);

Full signature of the deferred call:

createDeferredMandateSubscription(
    Model $owner,                                     // the owner model, or a Billable
    Subscription $subscription,
    PaymentMethod $paymentMethod,                     // must be recurring
    ProviderMandateModel $mandate,                    // usable, the owner's, matching the method
    string|CarbonImmutable|null $startDate = null,    // first BILLED calendar day; default today
    string|CarbonImmutable|null $paidThrough = null,  // lifts the past-start clamp
): BillableSubscription

Which mandate is accepted

Both calls collect from the mandate's own customer at the provider, so both check that the mandate is the subscriber's to use. Unless the mandate

the call throws a BillableSubscriptionServiceException before anything is written: no registration, no subscription, no charge, and nothing sent to the provider. In Connect mode, a billable whose tenant cannot be established is refused too, since its collections could not be routed either; a platform billable (dual billing) is checked on ownership alone.

The check runs again on the mandate row under a lock, where the mandate is linked to the subscription. A payment method removed in between is refused as well, and the creation rolls back.

When the immediate call cannot submit the charge, it throws a BillableSubscriptionServiceException. If the provider provably created nothing, the subscription is deactivated (MandateChargeExecutionFailed) and removed; if the outcome is unknown, it is left for reconciliation.

The start date is a calendar day

start_date is the first day that is billed. isUpcoming() and the planner work in whole days: the subscription is active, and collectable, from 00:00 of its start date. With a trial that ends mid-day ("trial ends the 15th at 14:30") the right start date is the 16th.

The deferred call enforces that: a $startDate carrying a time component is rounded up to the next calendar day, with a log line. Flooring it would bill a day the trial still covered. An explicit midnight date is taken as-is, and an import (paidThrough given) keeps its historic day, because there the date anchors the anniversary.

A $paidThrough timestamp floors to its own day, which is the safe direction: "covered until the 15th, 14:30" makes the 15th the last covered day.

Why access follows activation

Until start_date the deferred subscription is upcoming — not active per isActive() — so any trial access you grant keeps applying, and the planner only starts billing once the start date arrives.

This is deliberate for direct debit. SEPA settlement is asynchronous (days, not seconds), so access must follow activation, never payment confirmation; otherwise a subscriber loses access in the window between the charge and its settlement. Do not reach for the immediate variant during a trial: it charges at once and forfeits the remaining trial days.

Guards on the deferred call

A recurring payment method is required, and the mandate must pass the checks both calls share, re-verified under a row lock. Each failure throws a BillableSubscriptionServiceException.

Add-ons are yours to attach. Use SubscriptionCostService::addLine(..., startsOn: $subscription->start_date) with the stored, normalised start date, since a past $trialEndsAt clamps to today and a time component rounds up.

Changing plan

A plan change ends the current subscription and creates a new one on the target plan, carrying the unused prepaid days over as credit.

$new = app(BillableSubscriptionService::class)
    ->changeSubscription($current, $newPlan, $paymentMethod);

The fourth argument is an optional ?Closure $beforeFirstCharge; without it, subscription.before_first_charge applies.

What happens, in order:

  1. Validation. The subscription must be active, the target a different plan, and the target one the subscriber is actually offered — availableChangeTargets($tenant), the same set the change dialog renders from. With minimum_days_before_change set (on the plan, or in config), that many days must have passed since activation. A change is refused while a chargeback follow-up is unpaid.
  2. Credit. Prorated credit is calculated for the remaining prepaid days (calculateProratedCredit()).
  3. Wind-down. Charges not yet submitted are removed (their credit returned and their cost-line bookkeeping rolled back), all cost lines end per yesterday — prepaid days come back as credit — and the old subscription is deactivated (Changed) with yesterday as its end date.
  4. The new subscription. It is created active from today (activation_source = change) and takes over the mandate. Your beforeFirstCharge hook runs here.
  5. Money. The prorated credit and the old subscription's credit_amount go to the billable's credit balance; the first charge of the new plan is generated and the credit applied to it. A charge the credit covers fully is settled at once. A free target plan gets no charge.
  6. Collection. The charge is submitted to the provider after the transaction commits. A failure there flags the charge for attention; it does not undo the change.
  7. Event. BillableSubscriptionChangedEvent is dispatched.

Cost lines do not transfer. Your app owns its module and add-on state and re-adds the lines on the new subscription. The main route is the beforeFirstCharge hook on changeSubscription() (step 4 runs before step 5, so the change charge collects the re-added lines and the credit settles over the full amount). A BillableSubscriptionChangedEvent listener still works, with a next-period catch-up as its behaviour. Never both — see the adoption contract in configuration.

Cancelling, terminating and ending

There are three distinct operations, and picking the wrong one is expensive.

Call For Result
cancelSubscription() The customer cancels Runs to the end of the notice period; an upcoming subscription ends at once
terminateSubscription() An operator cuts a running subscription off Deactivated, ends today
terminateFailedSubscription() The customer gives up on a failed first payment The subscription is removed

None of them touches the mandate. The mandate belongs to the billable, not to the subscription: to offboard a customer, revoke it through MandateService::removeMandate() (in-flight-charge guard, reassignment, best-effort provider revoke) — never the raw provider call, and never as a side effect of cancellation. See Payment methods and mandates.

cancelSubscription()

The customer-facing call, and the only one you need for both shapes:

$result = app(BillableSubscriptionService::class)
    ->cancelSubscription($subscription, reason: 'Too expensive');

$result['end_date'];   // the last covered day
$result['message'];    // a translated sentence for the customer

Signature: cancelSubscription(BillableSubscription $subscription, string $reason = '', bool $skipOwnershipCheck = false): array. By default it checks that the authenticated user owns the subscription. Pass skipOwnershipCheck: true from an operator action, a queue job or a headless flow that has already authorised the act.

It refuses a subscription that is already cancelled, and a running fixed-term subscription without automatic renewal (willEnd()): that one ends by itself on its end date. Every other cancellation dispatches BillableSubscriptionCancelledEvent with the cancellation array.

terminateSubscription()

The admin hammer for a running subscription: deactivated with DeactivationReason::Terminated, canceled_at set, end date today, future charges removed, and BillableSubscriptionEndedEvent dispatched. It requires an activated, not yet deactivated subscription, and it gives no credit for the rest of the period already paid. The Nova and Filament companions offer it as an action; the bundled customer dashboards do not route it.

app(BillableSubscriptionService::class)->terminateSubscription($subscription, reason: 'Fraud');

terminateFailedSubscription()

Deletes (soft-deletes) a subscription with a failed first payment. This is what the bundled HTTP route named terminate calls, and it is the subscriber's own way out of an abandoned checkout.

It checks that the authenticated user owns the subscription (unless you pass skipOwnershipCheck: true) and that its first payment failed — the second time on the locked row. It refuses while a claimed payment attempt is still live, because deleting then could leave money collected with no subscription to book it against. Before deleting, it rolls back the cost-line bookkeeping of every unpaid charge and removes the charges that were never submitted to the provider, returning their credit.

Retrying a failed first payment

retryFailedFirstPayment() creates a fresh first charge for a subscription whose first payment failed and returns the redirect to its checkout. It collects the price frozen on the subscription. When a checkout for an earlier attempt is still open at the provider, it redirects there instead of creating a new one.

return app(BillableSubscriptionService::class)->retryFailedFirstPayment($subscription);

It refuses with a translated BillableSubscriptionServiceException when:

Refusal Why
The authenticated user does not own the subscription (unless skipOwnershipCheck: true) It is a customer action
The subscription has no failed first payment (asked again on the locked row) A paid webhook may have landed in between
The plan behind it is deleted or no longer active A subscriber must not check out against withdrawn pricing
A fixed-term contract's end date has passed The charge would bill a period after the contract end
Another subscription on the billable blocks it Reviving the row would put two collecting subscriptions on one billable
A payment attempt is in flight without a checkout URL Another request is creating it, or it awaits reconciliation

In Connect mode a draining tenant refuses with ConnectDisconnectPendingException. The dashboard drops the retry button where the plan was withdrawn; terminate and the plan picker stay available, so there is always a way forward.

Like terminateFailedSubscription(), it checks ownership against the authenticated user, so both are made for the customer's own request; in explicit resolution mode that check needs billable.owner_resolver. A caller that authorised the act itself — a console command, an admin action, a headless host — passes skipOwnershipCheck: true to either, as with cancelSubscription():

app(BillableSubscriptionService::class)->terminateFailedSubscription($subscription, skipOwnershipCheck: true);

Previewing a cancellation

computeCancellation() returns what a cancellation would do without performing it — useful for a confirmation screen:

$preview = app(BillableSubscriptionService::class)->computeCancellation($subscription);

$preview['end_date'];            // 'Y-m-d'
$preview['remaining_charges'];   // charges still to come before the end date
$preview['last_charge'];         // 'Y-m-d' or null
$preview['direct'];              // true when no notice period applies or no charge remains

Moving the start date

Do not write start_date on the model: a change that would strand cost lines, or one on a subscription that has any charge, throws a LarsaSubException. Use the service, which moves the open cost lines with the date in one transaction:

app(BillableSubscriptionService::class)->moveStartDate($subscription, '2026-03-01');

It refuses a subscription that has been billed. See Moving the subscription's start date for what moves and what does not.

Renewal

A fixed-term subscription with automatic_renewal = true is announced by larsasub:announce-renewal-billable-subscriptions — from two weeks before the last day the subscriber can cancel without entering the notice period, with BillableSubscriptionRenewalAnnouncedEvent — and renewed by larsasub:renew-billable-subscriptions once its end date has passed.

Renewal converts the existing subscription in place to continuous: it clears end_date, switches cancellation_notice_period to the renewal_cancellation_notice_period, and sets automatic_renewal to false. No new BillableSubscription is created, so cost lines, the mandate and the billing history simply keep running. BillableSubscriptionRenewedEvent is dispatched.

With the default schedule the renewal runs just before larsasub:plan-charges, so the renewed term's first charge is planned and collected on its first day. Keep the renewal daily: boot and larsasub:doctor warn about a frequency that collects that charge late (Scheduling). They judge the schedule LarsaSub registers; when you register the commands yourself, nothing checks them, so keep the renewal before plan-charges and daily yourself.

Subscriptions with automatic_renewal = false are deactivated (ContractPeriodCompleted) by larsasub:end-billable-subscriptions when their end date passes, with BillableSubscriptionEndedEvent. That command is not run-limited, which creates a race worth understanding if you cap larsasub:execute-charges.

Features

Feature keys are free-form. Resolution order is the config defaults (features.default for a global plan, features.tenant for a tenant's plan) → Subscription.features → BillableSubscription.feature_overrides.

// On the owner model (requires the HasSubscriptions trait)
$user->featureList();
$user->hasFeature('max_users');
$user->featureValue('max_users');

// On a subscription model
$subscription->features();
$subscription->hasFeature('max_users');
$subscription->featureValue('max_users', 5);

// On a plan: config defaults merged with the plan's own values
$plan->resolvedFeatures();

The owner-model methods read the owner's active subscription and answer empty, false or null without one.

A subscription reads its plan also after the plan has been soft-deleted, the same plan its VAT rate comes from (vatPlan()). A subscriber you keep billing on a deleted plan keeps that plan's features, and a tenant's plan keeps the features.tenant block.

Note the asymmetry: the owner-model featureValue() takes no default argument, while the subscription-model one does. On the owner model, resolve a missing value yourself ($user->featureValue('max_users') ?? 5). Always call features() on a subscription as a method — the instance has no features column.

config('larsasub.features.labels') maps keys to display names for Nova and the dashboards; unlabeled keys fall back to the raw key.

Query scopes

Use these for listings, reports and your own scheduled jobs. Start every chain with query() — the note below explains why.

// Status
BillableSubscription::query()->isActive()->get();
BillableSubscription::query()->isUpcoming()->get();
BillableSubscription::query()->isInactive()->get();
BillableSubscription::query()->isActivated()->get();
BillableSubscription::query()->isNotActivated()->get();
BillableSubscription::query()->isDeactivated()->get();
BillableSubscription::query()->isNotDeactivated()->get();

// isActive(), isUpcoming() and isInactive() accept an optional check date
BillableSubscription::query()->isActive($checkDate)->get();

// Charging, duration, renewal
BillableSubscription::query()->isChargeable()->get();
BillableSubscription::query()->hasEndDate()->get();
BillableSubscription::query()->hasNoEndDate()->get();
BillableSubscription::query()->hasAutoRenewal()->get();
BillableSubscription::query()->hasNoAutoRenewal()->get();
BillableSubscription::query()->endDatePassed()->get();
BillableSubscription::query()->endDateNotPassed()->get();

// Renewal announcements
BillableSubscription::query()->renewalNotAnnounced()->get();

// A tenant's subscriptions (through the plan; strict — no global plans)
BillableSubscription::query()->forTenant($tenant)->get();

isCancelled(), willRenew() and willEnd() are instance methods, not query scopes — $subscription->isCancelled(), not BillableSubscription::isCancelled(), which fatals. Only the names listed above are scopes. isActive(), isUpcoming(), isInactive() and isChargeable() have both an instance method and a scope, kept in sync. That is why every listing here starts with query(): PHP resolves BillableSubscription::isActive() against the instance method and fatals before Eloquent can forward it to the scope. Model::query()->scope() always works.

Plan-side scopes:

Subscription::query()->isEnabled()->get();
Subscription::query()->isDisabled()->get();
Subscription::query()->hasEndDate()->get();
Subscription::query()->endDatePassed()->get();
// …and hasNoEndDate(), hasAutoRenewal(), hasNoAutoRenewal(), endDateNotPassed()

// Tenant scoping (ScopedToTenant — explicit null policy)
Subscription::query()->forTenant($tenant)->get();                      // strictly the tenant's plans
Subscription::query()->forTenant($tenant, includeGlobal: true)->get();  // plus global plans
Subscription::query()->forTenant(null)->get();                          // global plans only