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.
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:
- Fabricating an entirely new chain for a subject — a single row for a fresh
subject_tokenpointing 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 carriesroot_proof, an HMAC over the token that only a holder oftamper_evidence_keycan 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. - 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.connectionatnull, 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:
| Unconfirmed | Confirmed | |
|---|---|---|
statusFor()['…']['accepted_major'] | 0 | the active major |
hasCurrent() | false | true |
outstanding() / the gate | never, either way — a voluntary consent may not gate (Art. 7(4)) | |
statusFor()['…']['pending_confirmation'] | true | false |
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:
reason | When |
|---|---|
no_pending_request | nothing was entered, or it was already confirmed, withdrawn or superseded — this is also what a second click on the same link gets |
window_closed | the request is older than double_opt_in.confirm_within |
superseded | a 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.