Optional features
Each of these is off by default. Three 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.
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
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.
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.
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.
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".
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 a relative-time string ('48 hours') 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 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.