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

Terms acceptance

Opt-in. Your app owns the terms documents and their versioning; LarsaSub owns the gate in its own subscribe flow and the acceptance evidence. This page covers plugging your terms in, what the bundled subscribe flow enforces, and the evidence it records. You need it when subscribers must accept your terms or waive their withdrawal right before they pay.

null (the default) disables the feature entirely — zero behavior change.

How it works

Configure a terms provider:

'terms' => [
    'provider' => \App\Billing\MyTermsProvider::class,   // LARSASUB_TERMS_PROVIDER
    'documents' => [],                                  // read by FileTermsProvider only
    'withdrawal_consent' => false,                      // LARSASUB_TERMS_WITHDRAWAL_CONSENT
],

With a provider configured, the bundled subscribe flow renders a required terms checkbox with a link to the document, and records the acceptance before it calls the payment provider.

The TermsProvider contract

Contracts\TermsProvider has one method: return the document in force for a tenant, in the locale closest to the one requested.

use Illuminate\Database\Eloquent\Model;
use PetersDevelopment\LarsaSub\Contracts\TermsProvider;
use PetersDevelopment\LarsaSub\Support\TermsDocument;

class MyTermsProvider implements TermsProvider
{
    public function current(?Model $tenant, ?string $locale): ?TermsDocument
    {
        $terms = Terms::currentFor($tenant);

        return $terms === null ? null : new TermsDocument(
            identifier: $terms->version,     // stable, language-independent
            title: $terms->title,
            url: route('terms.show', $terms),
            locale: $terms->locale,          // the language ACTUALLY served
        );
    }
}

Returning null means no terms apply: nothing is rendered and nothing is enforced. $tenant is null outside Connect mode.

TermsDocument is a readonly value object:

Argument Notes
identifier Required. A stable, language-independent version reference, max 128 characters
title Required
url A link to the document — or content must be given
content Markdown, rendered on larsasub.my-subscription.terms
effectiveAt Optional CarbonImmutable
locale The language actually served, not the one requested

It throws a LarsaSubException when the identifier is blank or too long, when url/content are blank strings, or when neither is supplied — a subscriber must be able to read what they accept.

The identifier is a string, deliberately not a foreign key. The version lives in your app. And a translation of a version is not a new version: the served locale is a separate field, so the record says both which version was accepted and which rendition the subscriber actually saw.

The bundled FileTermsProvider

For apps without a terms system: versions declared in config, content in markdown files, git history as the audit trail.

'terms' => [
    'provider' => \PetersDevelopment\LarsaSub\Support\FileTermsProvider::class,
    'documents' => [
        [
            'identifier'   => 'terms-2026-09',
            'title'        => 'Terms and conditions',
            'effective_at' => '2026-09-01',
            'locales'      => [
                'nl' => resource_path('terms/2026-09.nl.md'),
                'en' => resource_path('terms/2026-09.en.md'),
            ],
        ],
        [
            'identifier'   => 'terms-2026-01',
            'title'        => 'Terms and conditions',
            'effective_at' => '2026-01-01',
            'file'         => resource_path('terms/2026-01.md'),  // single-language
        ],
    ],
],

Append versions; never edit a shipped entry. Acceptance records point at the identifier, and evidence is only as good as the version discipline. Editing an entry rewrites what past subscribers are recorded as having accepted.

Content renders on the larsasub.my-subscription.terms route with raw HTML escaped and unsafe links stripped. A document with a url instead of content redirects there.

Do not use the TranslatableText cast for terms content. That cast is for mutable, merge-written content; terms are immutable snapshots.

Where enforcement lives

In the HTTP layer of the bundled subscription dashboard only — its subscribe action (larsasub.my-subscription.create):

Only that action is gated: a plan change (larsasub.my-subscription.change) and a retried first payment (larsasub.my-subscription.retry-first-payment) ask for no new acceptance.

Service signatures are untouched. Headless apps that call BillableSubscriptionService or the provider directly form the contract in their own flow and own their own consent — they can call TermsAcceptanceService themselves if they want LarsaSub-side records.

The evidence record

Models\TermsAcceptance (larsasub_terms_acceptances), written only through Service\TermsAcceptanceService:

Column Description
billable_id Who accepted
billable_subscription_id The subscription it led to (linked afterwards)
terms_identifier The version accepted
terms_locale The rendition served
context subscribe, or withdrawal-consent
accepted_at
client_hash Keyed one-way hash (HMAC) of IP + user agent — never the raw values

The table is append-only. The single permitted update is linking the created subscription afterwards, once; any other update or a delete throws a LarsaSubException. Query-builder mass writes bypass that guard, so treat the table as the service's.

Acceptance is recorded before the provider call: the act of accepting is real regardless of whether the payment succeeds. A subscription that is not created leaves the acceptance unlinked; the subscriber ticks again on the next attempt.

TermsAcceptedEvent fires on every record, so you can mirror into your own consent store.

The only path that removes acceptances is larsasub:prune-billables --orphaned, which deletes a registration whose owner no longer exists — and only one without financial history — together with its evidence. See Compliance.

Withdrawal consent

'terms' => [
    'withdrawal_consent' => true,
],

Only read when a provider is configured. With it on, the subscribe flow requires a second checkbox: express consent to immediate performance, plus acknowledgement of losing the 14-day withdrawal right for consumer distance contracts.

It is recorded as a separate acceptance with context withdrawal-consent and linked to the same subscription, as your evidence. The package does not send a confirmation of both consents on a durable medium; that confirmation is yours to send. The pro-rata refund on an actual withdrawal runs through the normal refund and credit paths. See Compliance.

Recording acceptance yourself

A headless flow records the same evidence through TermsAcceptanceService:

use PetersDevelopment\LarsaSub\Service\TermsAcceptanceService;

$terms = app(TermsAcceptanceService::class);

$document = $terms->currentDocument($tenant, app()->getLocale());

if ($document !== null) {
    $acceptance = $terms->record(
        billable: $billable,
        document: $document,
        clientHash: $terms->fingerprint($request),
    );

    $terms->linkSubscription($acceptance, $billableSubscription);
}

$terms->hasAccepted($billable, 'terms-2026-09');   // bool
$terms->provider();                                 // the configured provider, or null

record() takes a Billable registration (from BillableResolver::getOrCreateBillable()), and an optional context: TermsAcceptanceService::CONTEXT_SUBSCRIBE (the default) or TermsAcceptanceService::CONTEXT_WITHDRAWAL_CONSENT for a second record next to it. hasAccepted() answers whether this billable ever accepted that version, in any context.

fingerprint() derives the keyed hash from the request (null only when both the IP and the user agent are missing); never store the raw IP or user agent yourself.