Skip to main content

Optional features

Each of these is off by default. Four turn on with a single config switch; the double opt-in has none — it is on as soon as double_opt_in.confirm_within carries a duration, and null (the default) means the middle state is never entered.

The document fragment​

'routes' => [
'fragment' => true, // default: false
'fragment_prefix' => 'legal',
'fragment_middleware' => ['web'],
],

Registers GET {prefix}/document/{key}/{locale}, which returns one published document's text as a fragment — no layout, no <html>, no stylesheet. It exists so a dialog on your registration form can hold the text a consent checkbox names, instead of sending the reader away from a half-filled form to read it.

The fragment opens with the document's title and its version. Add heading=0 and it leaves the title out and keeps the version: the dialog asks for it that way, because its own header already names the document. In the WireKit views the text keeps a readable line length, and measure=none takes that limit off: the dialog asks for this too, because its panel already sets the line length, and the limit inside it left an empty strip beside the text.

The body is not in the page until somebody asks for it, and that is the point. A registration form can show four documents; a privacy notice is tens of kilobytes of HTML; in the normal case the reader opens none of them. Inlining all four would be a large certain cost for an uncommon benefit, on the page whose load time decides whether somebody registers at all.

The dialog that uses it​

'ui' => [
'wording_dialog' => true, // default: false — needs `routes.fragment` above
],

The WireKit consent checkboxes then open the document in a dialog over the form instead of sending the reader to the page — every document, including each one named in a grouped sentence, in a dialog of its own. It takes both switches: without the fragment route there is no address to fetch from, so there is no dialog and the anchor is exactly what it was. That happens silently, so from the outside a missing route looks exactly like a dialog that does not work.

The anchor keeps its real address, and that is the shape of the whole thing. A consent binds only where the full text was reachable before agreeing (§ 305 Abs. 2 BGB), and a dialog opened by script is not reachable without script. So the dialog sits on top of a link that works without it — never in its place, and never behind a #. For the same reason the link is announced as opening a dialog only where scripting is actually running: that attribute is bound, not written. The new tab is announced the other way round, only where scripting does not run, and the link draws no new-tab arrow, because for every reader whose browser runs the dialog that arrow would be wrong.

And the dialog carries the page itself, in every state. With scripting running, the anchor swallows its click to open the dialog, so it can never be the way out of one. The dialog's footer therefore holds its own link to the published page, rendered by the server and opening in a new tab so the form keeps what was typed. It is there while the text loads, after it failed, and when the body stays empty for a reason the dialog cannot see — a script that was never published, or one older than the view. A fetch that fails also says so: a dialog that opened onto an empty box would read as a document with no content, to somebody who is then asked to tick that they read it.

It needs one published file​

The dialog's Alpine component is a published asset rather than something written into the template, and the reason is a grammar rather than a preference. Alpine's CSP build parses the expressions in your attributes itself instead of handing them to eval, and it refuses arrow functions, optional chaining and more than one statement in an attribute — which is to say, every way of writing a fetch chain inline. Reduced to a call, the same attribute parses under both builds, and the logic lives where none of those limits apply.

php artisan vendor:publish --tag=legal-consent-assets
<script src="{{ asset('vendor/legal-consent/legal-consent.js') }}" defer></script>

Before Alpine. It registers itself on alpine:init, so there is nothing to import and no bundler to run — WireKit ships a prebuilt bundle for the same reason, and an optional feature that demanded a build step would take that away. A served file is also what a strict policy expects: script-src 'self' covers it, where an inline script is the thing such a policy exists to refuse.

The text goes into the dialog through a ref rather than x-html, because the CSP build refuses the x-html directive outright, whatever its expression.

Without the file the dialog opens without its text. The anchor still carries the document's real address, but wherever Alpine runs it swallows the click to open the dialog — so the address is not where the reader ends up, and the dialog's own page link is what is left. Enabling the switch without publishing the file is the one way to get a control that looks live and is not.

And a published file is a copy. Composer updates the package, not the file in your public directory, and a script older than the view leaves the dialog without its text in exactly the same way. Publishing it on every update keeps the two in step:

"scripts": {
"post-update-cmd": [
"@php artisan vendor:publish --tag=legal-consent-assets --force"
]
}

The plain stub does none of this and is not going to: it names no framework and needs no JavaScript.

What it will not serve​

Published documents only, under a registered key, in a configured locale. Anything else is a 404 — including a key that exists only as a draft. A draft is work in progress and has never been anybody's terms, and an empty fragment would read, to somebody about to agree, as "there is nothing here to read".

It is unauthenticated by default, and that is deliberate​

It is the one route here whose default stack has no auth. Somebody reading a registration form has not signed up yet, and a login wall in front of the terms they are being asked to accept is the failure this seam exists to remove. It adds no exposure either way: what it returns is the text you already publish at the address the checkbox links to — only the shape differs.

The link is the half that carries the clickwrap

With this off, the anchor in a consent sentence keeps opening your own page, and that is not a consolation prize. A consent binds only where the full text was reachable before agreeing (§ 305 Abs. 2 BGB), and a dialog opened by script is not reachable without script. The dialog belongs over that link, never in its place — so the anchor keeps a real address whether this route is on or off.

Tamper evidence​

tamper_evidence (default false) turns on the append-only hash chain. Every new ledger row links to the subject's previous one, so tampering that does not re-chain — a naive edit, deletion, or reorder — is detectable. Verify it with legal-consent:verify-ledger, which exits non-zero on a break.

Keying the chain​

By default the hash is unkeyed, so it does not catch an actor with write access who recomputes the chain to hide a change. Set tamper_evidence_key (from LEGAL_CONSENT_TAMPER_KEY, held outside the database) to HMAC-key the chain — a table-write attacker without the secret can then no longer re-chain edited history.

Fix the secret before the first chained row; append-only rows cannot be re-keyed.

How strong the secret has to be​

Use at least 256 bits from a cryptographic random source, and nothing derived from a password:

php -r 'echo base64_encode(random_bytes(32)), PHP_EOL;'
# or
openssl rand -base64 32

legal-consent:doctor names a key that holds fewer than 32 bytes, measuring a key written base64:…, the way Laravel writes APP_KEY, by the bytes it decodes to. A length is the one thing it can measure: it says nothing about a passphrase of any length, which is why the advice above asks for random bytes.

The reason is specific to this design rather than general advice. Every chained row stores its content in the clear next to a MAC computed over it, so anyone who obtains a copy of the table holds an unlimited supply of known plaintext/MAC pairs. That is exactly the input an offline guessing attack wants, and it runs at whatever rate the attacker's hardware allows, with nothing in this package able to observe or slow it. A memorable passphrase does not survive that; 32 random bytes do.

Store it where the database backup cannot reach it. A secret kept in the same dump as the ledger protects nothing against the person holding the dump.

Pin the engine and the session time zone before the first chained row​

The chain hashes accepted_at as the string the database driver returns. PostgreSQL renders a timestamptz with an offset, MySQL and SQLite render none, and on PostgreSQL and MySQL the rendering follows the session time zone. Once the first row is chained, restoring a dump onto another engine or changing the session time zone makes legal-consent:verify-ledger report every chained row as broken although none was touched.

Laravel sets the session time zone only from a timezone key on the connection, and the pgsql and mysql connections in Laravel's own config/database.php carry none. Without the key the server decides: on PostgreSQL its TimeZone setting, a database or role default (ALTER DATABASE … SET timezone), or PGTZ in the environment of the process that connects; on MySQL its time_zone setting. Pin it before the first chained row:

// config/database.php
'pgsql' => [
// …
'timezone' => 'UTC',
],
'mysql' => [
// …
'timezone' => '+00:00', // a zone name needs MySQL's time zone tables
],

Pin it to the application's time zone. Laravel writes accepted_at as the wall time of app.timezone, without an offset, and the database reads that wall time in the session's zone: a session in another zone stores every recorded moment shifted by the difference, with or without tamper evidence. legal-consent:doctor names that shift on PostgreSQL and MySQL and fails for it.

On a ledger that already has chained rows, set the key to the zone those rows were written under, so their rendering stays the same. SHOW TimeZone on PostgreSQL and SELECT @@session.time_zone on MySQL report the zone in effect now, and legal-consent:doctor names it while tamper evidence is on and no key pins it.

Keying narrows what can be forged — it does not make the ledger unforgeable​

Each row stores only the link to its predecessor, never its own hash. Two attacks followed from that for anyone able to INSERT, and one of them is closed as of 0.22.0:

  1. Fabricating an entirely new chain for a subject — a single row for a fresh subject_token pointing at the root, which the verifier walked as a valid chain of its own. Closed on a keyed installation: the row that opens a chain now carries root_proof, an HMAC over the token that only a holder of tamper_evidence_key can produce, and the verifier requires it. A marker records the point from which proofs are required, so a missing one below that boundary is history rather than a forgery — which also means rows written before you set a key stay verifiable and stay unprotected.
  2. Replacing — not merely truncating — a subject's newest row. Still open, and it cannot be closed by keying alone. The link lives on the row that follows, so the last row of a chain has nothing after it to disagree with. Only an append-only trigger, or a witness kept outside this database, sees that one.

With no tamper_evidence_key configured, neither half applies: there is no secret to prove anything with, and attack 1 works exactly as before.

Read php artisan legal-consent:verify-ledger's "intact" accordingly — on a keyed installation it means no evidence of re-chaining, and every chain above the boundary was opened by a key holder; on an unkeyed one it means only the first of those. Neither is proof of authenticity. The primary defense is still the database append-only trigger plus restricting INSERT on legal_consents to the application role.

Concurrent first consents on MySQL​

A chained write reads the subject's newest row with FOR UPDATE. On MySQL, for a subject with no chained row yet, that read locks a gap in the index, and two first consents in the same gap can deadlock. The package tries such a write up to five times. It cannot when you call it inside a transaction of your own: MySQL has rolled back that whole transaction, so Laravel hands you a DeadlockException, and the retry belongs around your transaction, for example DB::transaction($callback, attempts: 3).

Age gate​

age_gate (enabled: false, threshold: 16) implements Art. 8 GDPR. When on, registration additionally requires an age_confirmed attestation. The package gates on the attestation; verifying the actual age stays your app's job.

The attestation appears on the registration checklist under its own key rather than a legal_{key} field name.

The attestation is NOT written to the ledger, and you probably need to store it​

This is the sentence to read twice, because the rest of this package sets the opposite expectation: everything a subject agrees to during registration lands in legal_consents as a proof row, and the age attestation does not. Validation refuses the registration without it, and then nothing about it is persisted anywhere.

That is deliberate, and the reason is the shape of the ledger rather than the importance of the fact. Every row there is evidence about a document: it carries the key, the version, the content hash and the exact wording the subject was shown, and its whole worth is that those five things cannot drift apart. An age attestation has none of them — there is no document, no version and no wording to freeze. Writing it as a row would mean inventing a pseudo-document to hold a checkbox, and a legal ledger whose rows are not all evidence of the same kind is worth less than one that refuses the row.

So the obligation moves to you, and it is a real one. Under Art. 8 a controller must be able to show that a reasonable effort was made to verify age. A gate that leaves no trace cannot show anything afterwards. Store the attestation where your own registration data lives — a column on the user, a row in your own table — together with the timestamp and the threshold that applied at the time, since threshold is configuration and configuration moves.

// in your registration controller, after validation has passed
$user->forceFill([
'age_confirmed_at' => now(),
'age_threshold_at_signup' => config('legal-consent.age_gate.threshold'),
])->save();

If you would rather the package carried this, that is a reasonable thing to want and it needs a schema of its own rather than a corner of this one — say so and it can be designed.

Multi-tenancy​

tenancy (enabled: false) scopes documents and consents per tenant. Register a resolver in a service provider's boot():

app(\Pushery\LegalConsent\Support\TenantContext::class)
->resolveUsing(fn () => auth()->user()?->tenant_id);

Each tenant gets its own active version of a (key, locale); admin sweeps (prune, dispatch-notices) run across all tenants.

The resolver returns the tenant's key as an int or a string, or null where there is no tenant. A backed enum counts as its value, and a Stringable such as a UUID object as its string. Any other answer is refused with Pushery\LegalConsent\Exceptions\UnresolvableTenant, a model included: return tenant()?->getKey() rather than tenant(). Read as no tenant, such an answer would put every tenant into the shared bucket, where each one sees the documents and consents of the others.

A resolver that reads the signed-in user yields nothing for a guest, and a new account is a guest while its registration consents are recorded: the documents would be read, and the rows written, in the shared bucket, where the account's own tenant never looks again. Tell the package the tenant of the account being registered:

app(\Pushery\LegalConsent\Support\TenantContext::class)
->resolveSubjectUsing(fn ($user) => $user->tenant_id);

It is asked only while the request itself names no tenant. A resolver that reads the tenant from the request, a domain or a route parameter, already works for a guest and needs nothing more.

Commands​

A command runs in the console, where a resolver that reads the signed-in user yields no tenant. legal-consent:check-drift and legal-consent:rerender then work through every tenant that holds a document or a draft, and the shared bucket, the way the sweeps do. legal-consent:publish and legal-consent:changes work in the shared bucket and say so. Name a tenant with --tenant=<id> and the two checks look at that tenant only, while the other two work in it:

php artisan legal-consent:publish terms de --active --tenant=acme

Where the console does resolve a tenant, for example because a tenancy package runs each command once per tenant, every command works in that one.

Texts per shop, in a tenant's database​

The tenant is whatever the resolver answers, so it can be finer than your own tenants: a shop, a brand, a storefront. Each answer gets its own active version of every (key, locale) and its own consents. An application that keeps each tenant's data in a database of its own, with stancl/tenancy for example, and versions each shop's terms on their own, switches tenancy on as above and does three things:

  • Leave database.connection at null, so the package uses the default connection, which the tenancy package points at the tenant's database, and add the package's migrations to your tenant migrations. The texts and the consents are then kept, exported and deleted with the tenant.
  • Answer the resolver with the shop, as a string of at most 64 characters: 'shop:'.$shop->getKey(), or 'tenant-7:shop-42' where one database holds several tenants.
  • Fill the shop's values in your source with Placeholders, so every placeholder is checked when the text is published and the content hash covers the text with the shop's values in it:
public function resolve(string $type, string $locale): RawDocument
{
$shop = $this->shops->current();
$text = $shop->legalTexts()->where('key', $type)->where('locale', $locale)->firstOrFail();

return new RawDocument(
type: $type,
locale: $locale,
title: $text->title,
body: (new Placeholders([
'shop.name' => $shop->name,
'shop.domain' => $shop->domain,
]))->fill($text->body, ContentFormat::Markdown),
format: ContentFormat::Markdown,
version: $text->version,
uiWording: $text->wording,
);
}

Double opt-in​

For advertising e-mail the confirmed double opt-in is the German benchmark (§ 7 Abs. 2 UWG together with Art. 7 DSGVO), and the burden of proof is the controller's (Art. 7(1)). The ledger could always write both steps; what it could not do was tell them apart, because two granted rows with different timestamps are all that was left. Whoever had to prove which one was the confirmation could only assert that the second one was.

Two rows, two actions:

// 1. Somebody entered themselves. NOT yet a consent.
Consent::requestConfirmation($subject, 'newsletter', $context); // action: optin_requested

// 2. They followed the link in the mail. THIS is the consent.
Consent::confirm($subject, 'newsletter', $context); // action: confirmed

What the first row is worth until the second one arrives​

Nothing — and that is the decision, not an omission. An unconfirmed entry is a declaration nobody has yet tied to the address it names, which is precisely not a valid consent. So it does not count anywhere:

UnconfirmedConfirmed
statusFor()['…']['accepted_major']0the active major
hasCurrent()falsetrue
outstanding() / the gatenever, either way — a voluntary consent may not gate (Art. 7(4))
statusFor()['…']['pending_confirmation']truefalse

The alternative — treating it as a consent that lapses if unconfirmed — was rejected: it would have the ledger assert, for the whole unconfirmed window, a consent that never existed, in a table nothing can correct afterwards.

pending_confirmation exists because that middle state is otherwise invisible: entered but not yet confirmed folds to exactly the same zero as never entered, so a screen built on the numbers alone would invite the subject to enter themselves a second time — and the second request supersedes the first, which stops the confirmation link already in their inbox from working. The bundled settings screens show it instead of offering the control again.

Sending the mail is yours; the seam is an event​

The package owns the ledger, not the mailbox. ConsentConfirmationRequested fires on the first row — listen there, mail a signed link, and call Consent::confirm() when it is followed.

It is a separate event on purpose. ConsentRecorded is what a consuming application provisions on, and an unconfirmed request reaching that listener would be the silent version of the whole problem: the row says "requested" while every listener hears "granted".

A queued listener reads the row back​

The ledger events (ConsentRecorded, ConsentWithdrawn, ConsentObjected, ConsentTerminated, ConsentConfirmationRequested), LegalDocumentPublished and NoticeDispatched serialize their model by its key. A listener that implements ShouldQueue therefore puts no IP address, user agent or request id on the queue, and none reaches failed_jobs when the job fails; the listener reads the row back from the database when it runs. After an erasure it reads the erased row, and after the retention sweep has removed the row the job fails with ModelNotFoundException. Mark the listener with #[DeleteWhenMissingModels] to drop such a job quietly instead.

LegalDocumentPublished waits for the commit​

The event is dispatched once the transaction that published the version commits. A release over several languages publishes them in one transaction, so a release that fails in a later language dispatches nothing, and a listener never acts on a version that was rolled back. It also means a listener runs after the release is final: an exception it throws no longer undoes the release.

When a confirmation is refused​

NotConfirmableException carries a reason, because "expired" and "already used" need different copy on the page and matching on the sentence is how that stops working quietly:

reasonWhen
no_pending_requestnothing was entered, or it was already confirmed, withdrawn or superseded — this is also what a second click on the same link gets
window_closedthe request is older than double_opt_in.confirm_within
supersededa new major version was published in between, so confirming would freeze a text the subject never read

confirm_within is null by default, meaning no limit — a limit nobody chose would start refusing confirmations an application was already accepting. Set an English relative time ('48 hours') or an ISO 8601 duration ('P2D') and it becomes the ledger-side half of what a signed URL's expiry does inside the link; an application that does not sign its links has no other check at all.

A value that names no positive period, such as '2 Tage' or '2 days ago', is not read as a window that has closed. Consent::confirm() throws InvalidConfirmationWindow with the key's name instead, and legal-consent:doctor reports the value before anyone confirms.

A document that is not a voluntary consent raises NotGrantableException instead, from both methods: a contract is agreed where its full text is presented, never by following a link.