Skip to main content

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:

  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.

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:

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

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