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
- The TermsProvider contract
- The bundled FileTermsProvider
- Where enforcement lives
- The evidence record
- Withdrawal consent
- Recording acceptance yourself
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
localeis 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
],
],
],
- Which version. The newest entry whose
effective_athas passed wins; entries without one count as always effective. - Which file.
localesfiles resolve through the package's content-locale order — requested, fallback, default, then the first listed — and the winning locale is recorded. A singlefilerecords the entry's optionallocalekey, or none. - Errors. An entry with neither
filenorlocales, or a file that cannot be read, throws aLarsaSubException.titledefaults to "Terms and conditions".
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
TranslatableTextcast 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):
- the checkbox is required per subscribe act — an earlier acceptance does not count;
- the posted
terms_identifieris re-checked server-side against a freshcurrent()call, which closes the version-changed-mid-session race; - a refusal returns to the dashboard with a translated message, before anything is recorded or created.
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.