Skip to main content

Recording consent

Four ways write the same ledger. The first three — the Fortify trait, the Registered listener and the headless JSON API — go through one registration recorder and share an idempotency flag, so they never double-write. The fourth has no registration form to hook into and records from an interstitial instead.

The registration checklist​

Consent::registrationChecklist() returns the controls a registration form must render. Each item is derived from what is actually published, never from a config entry alone. Without an argument it answers for the application locale, which is also what the registration rules and the recorder read, so the form shows the sentence the proof freezes.

Which documents it covers​

Every registered document that asks something — a contract, an acknowledgement, a consent — gets a control. An informational page never does: it binds nobody, so it is absent from the form, the gate and the notice sweeps alike. That is the right home for an Impressum (§ 5 DDG), a cookie policy or an accessibility statement, and getting it wrong is the common mistake: a record saying someone "accepted the Impressum" asserts a consent that does not exist in law.

For the other case — a document that genuinely binds, but is acknowledged later rather than at sign-up — set 'ask_at_registration' => false on its registry entry. The rules, the checklist and the recorder drop it together, which is the only safe way to do it: a rule with no control blocks a form nobody can satisfy, and a recorder that kept going would write proof of an acceptance the subject was never asked for. Nothing else changes — a mandatory document still gates, so the subject meets it at the re-consent screen. The flag moves when it is asked, never whether.

Set document_url and each item arrives with its url already resolved, from that item's document — so the link carries the item's locale rather than the page's, by construction. The shipped checkbox stubs render it; leave the key unset and they render the wording alone, with no link and no dangling description reference.

That locale distinction is the whole reason the resolver is handed the document rather than a key. Consent::published() deliberately has no fallback, so a URL built from the app locale points at nothing for a document that exists only in the default locale — and the visitor gets a required checkbox whose text they cannot open. The same applies if you build the link yourself: use Consent::published($item->key, $item->locale).

Each item also exposes field(), the input name the rules validate (legal_{key}, or the key itself for the age attestation). Take the name from there rather than building it. The two shapes differ for exactly one control, and it is the one that fails hardest: with the age gate on, a hand-built legal_{$item->key} renders a required legal_age_confirmed against a rule demanding age_confirmed, so the visitor can tick the box and never complete the registration. The shipped stubs read field from the item for this reason.

The accept-time content-hash guard​

Each item additionally exposes contentHash (the render-time fingerprint) and hashField() (legal_{key}_hash) — the opt-in accept-time guard. Render a hidden input named hashField() carrying contentHash, and a version published between page load and submit is caught (a DocumentChangedException) instead of silently freezing a text the visitor never saw — the same guarantee the re-consent form gives.

The shipped consent-checkboxes stub renders it automatically when you pass a checklist item's ->toArray(); omit those keys (the minimal $documents shape) and the registration path behaves exactly as before.

Rendering your own page: use the fingerprint, not the content hash​

When you build the consent screen yourself rather than using registrationChecklist(), take the same value from the published document:

$document = Consent::published('terms', $locale);

$fingerprint = $document->acceptanceFingerprint();

That is the value the accept-time guard compares against, and it is not the same as $document->contentHash. The content hash covers the sanitized body; the fingerprint folds in the acceptance sentence shown next to the checkbox. Two versions can share a body and differ only in what the subject agreed to — recording the body hash could not tell them apart.

Do not rebuild the hash yourself. A second implementation is exactly what stops a guard from being one: change the separator on one side and the two drift apart silently.

Under multi-tenancy, $document->tenantId tells you which tenant's text you are holding. Reads are already confined to the current tenant, so this is for confirming, not for filtering.

A sentence on every page: publishedVersion()​

A form that renders on every page, a feedback widget or a newsletter box, needs the acceptance sentence and the fingerprint, and links to the document rather than showing it. Consent::publishedVersion() answers with the version published() finds, without its text:

$version = Consent::publishedVersion('privacy', app()->getLocale()); // null when unpublished

$version?->uiWording; // the sentence beside the link
$version?->acceptanceFingerprint(); // the value to record with the acceptance

It is read from the cached set of active documents the consent gate already reads, so it costs no query after the first, and every write to the documents table through the package refreshes it. A row changed with raw SQL is the exception, as it is for the gate: run php artisan legal-consent:cache-flush after one. The fallback to another language is the one the text follows.

Under multi-tenancy, $version->tenantId names the tenant whose version you are holding, as $document->tenantId does for the text.

The form is dormant until you publish​

The validation rules, the checklist you render, and the row that gets recorded all resolve the same document: your configured keys intersected with what is actually published, falling back to the default-locale version for a mandatory document (a voluntary consent is never required — Art. 7(4) — and is simply not offered where it is unpublished). So the registration form is dormant until you publish: an unpublished document demands nothing, and the section appears the moment legal-consent:publish runs.

A document's legal nature comes from the published row, not from the config entry, so a drifted legal_basis cannot decide whether a checkbox is mandatory.

A box your own form renders for a mandatory document that is not published anywhere has no version behind it to record. If it comes back ticked, the registration fails with UnrecordableConsentException, because recording the rest would leave a consent you cannot prove. A box the document shares with published documents through registration_field is different: the tick answers for those, they are recorded, and the unpublished document stays open. The recorder logs it:

legal-consent: recorded no consent for a mandatory document with no published version behind a shared registration field
{"document_keys":["privacy"], "fields":["legal_notices"], "locales":["de"], "subject_type":"App\Models\User"}

Once you publish the document, the subject owes its first acceptance, which legal-consent.gate.first_use asks for at a first-use interstitial.

Way A — the Fortify CreateNewUser trait​

The strongest proof context:

use Pushery\LegalConsent\Concerns\RecordsRegistrationConsent;

class CreateNewUser implements CreatesNewUsers
{
use RecordsRegistrationConsent;

public function create(array $input): User
{
Validator::make($input, [/* … */] + $this->consentRules(), $this->consentMessages())->validate();

$user = User::create([/* … */]);

$this->recordRegistrationConsent($user, $input);

return $user;
}
}

What a checkbox may submit​

A mandatory document's control is held to Laravel's accepted rule. A voluntary consent's control takes any value a checkbox submits, ticked or not: what accepted takes (1, on, yes, true) records the consent, and what declined takes (0, off, no, false), or no value at all, records none. A checkbox without a value attribute submits on, so a form of your own needs no attribute, and a bound Livewire property holding true or false passes as well.

Any other value fails validation, through Pushery\LegalConsent\Support\CheckboxAnswer. A checkbox with a value of its own, such as value="newsletter", would otherwise reach the recorder as not given while the visitor had ticked it.

A sentence your form builds per request​

A registration line can name several texts under one checkbox, and the ledger freezes the sentence next to each of them. If you configure that sentence (registration_wording on the document), it is the same for everybody. If your form builds it per request instead, naming only the texts published in the reader's language for example, pass the sentence it showed:

$line = __('I have read and taken note of the :documents.', ['documents' => $names]);

$this->recordRegistrationConsent($user, $input, shownWordings: [
'privacy' => $line,
'imprint' => $line,
]);

Each document you name freezes that sentence instead of its configured one. A document you leave out keeps its configured sentence, and so does one you pass a blank value for. An informational page flagged acknowledge_at_registration needs no configured sentence of its own when you pass one here; without either, it is not recorded, because there is nothing truthful to write. Build the sentence on the server, from what the form rendered: the ledger stores it as the proof of what the person saw.

Such a page is recorded only when the request carries its control, ticked. That is legal_{key}, or the checkbox it shares with the documents beside it, which you name as registration_field on each of them. No validation rule makes that box required, because the page binds nobody, so an empty box is an answer and is recorded as none. A request without the control, such as a registration through an identity provider, acknowledges nothing and logs the field it was looking for.

Way B below cannot pass a sentence, because the Registered event carries no request-time wording. It freezes the configured sentences.

Way B — the Registered event listener​

No Fortify required. The listener is registered automatically; toggle it with legal-consent.registration.listen_to_registered_event.

It is on by default, and that is safe only because a form validated the tick​

The recorder checks the submitted field for a consent document and skips the key when it is absent. For a contract or an acknowledgement it does not — those are mandatory, RegistrationRules makes them required, and re-checking here would be a second truth about the same thing. So it accepts them unconditionally and relies on the form.

Sign people in through an external provider and there is no form. OAuth, SSO and invitation callbacks carry no such fields, so nothing validated the tick. With the listener on, the first callback writes an acceptance row for every mandatory document without a human having done anything — in the one table whose entire purpose is to prove that a human did.

It tells you when that happens​

Recording a mandatory document whose legal_<key> field is absent from the request logs a warning naming the keys, the subject type and the method:

legal-consent: recorded a mandatory consent with no registration-form field present
{"document_keys":["terms","privacy"], "subject_type":"App\Models\User", "method":"registration_checkbox"}

That is an observation about the request, not a guess about your application — the input either carried the field or it did not. Asking the router whether a registration form exists cannot be made reliable, because an application may name that route anything.

By default it warns and still records — and the default is deliberate rather than timid. The check can only look for the field name RegistrationRules generates, so an application with its own registration form, naming its fields differently, validates the tick perfectly well and still sends no legal_terms. To that application a warning is noise; a refusal would be an outage, on the one path every current consumer uses.

Make it refuse instead​

Where there genuinely is no form, a warning is the wrong strength. Switch it:

'registration' => ['without_form_fields' => 'refuse'], // default: 'warn'

refuse raises UnevidencedConsentException and records nothing — not even the documents resolved before the offending one. The recorder resolves the whole set before its first write, so a registration keeps all of its consents or none; a partial ledger is the one outcome an append-only table cannot recover from.

An unrecognized value means warn, because a typo must never be the thing that starts failing registrations. legal-consent:doctor reports it, which matters precisely because the fallback is silent: someone who wrote refuse with a typo believes they are refusing.

If your application has no registration form at all, the cleaner answer is usually to turn the listener off and capture the first acceptance where it actually happens — that is Way D:

'registration' => ['listen_to_registered_event' => false],

Way C — the headless JSON API​

Opt in via legal-consent.routes.api:

POST /legal/consent { "document_key": "newsletter" } → 201 (404 if unknown)
POST /legal/withdraw { "document_key": "newsletter" } → 204 (422 if not withdrawable, 404 if unknown)
POST /legal/object { "document_key": "terms" } → 201 (422 if not objectable, 404 if unknown)
POST /legal/terminate { "document_key": "terms" } → 201 (422 if not terminable, 404 if unknown)
GET /legal/status → 200 per-document status

POST /legal/consent also accepts an optional expected_content_hash — the hash the subject was shown. If the active document has been re-released since, the endpoint returns 409 document_changed instead of recording consent to a version the subject never saw (Art. 7(1)). The facade twin is the fifth argument: Consent::accept($user, $key, $context, $locale, $expectedContentHash).

The API compares the value; it does not hand it out. GET /legal/status carries no text and no hash, and the fragment route serves the body alone. A client that shows the acceptance sentence gets it from your backend together with its fingerprint, taken when the sentence is rendered:

$version = Consent::publishedVersion('newsletter', $locale);

$version?->uiWording; // the sentence the client shows
$version?->acceptanceFingerprint(); // what it sends back as expected_content_hash

Send the fingerprint captured with the sentence, never one fetched at the moment of the request: a value read when the subject clicks always matches the active version and guards nothing. A bare content_hash is still accepted and guards the body alone, not the sentence beside the checkbox.

Error responses​

Every writing endpoint answers a document_key that is not published with 404 unknown_document:

{ "error": "unknown_document", "message": "No legal document is published under the key 'privacy'.", "document_key": "privacy" }

Every error body carries error and message. document_key is on the 404 and the 409 — the two that name a specific document — and not on the 422s, which are already about the key you sent. A failed document_key validation is Laravel's own 422 and carries neither, only errors.

The three codes say different things, and the differences are worth wiring into your client:

CodeMeaningRetry?
404 unknown_documentthere is no such document published here — a typo, or a document unpublished sinceno; fix the key
422 not_withdrawable / not_objectable / not_terminablethe document is real, the transition is not one its legal class can carryno; the action does not apply
409 document_changeda new version was released since the subject was shown the textyes, after re-showing the current version

GET /legal/status names no document, so it has none of these: it reports what is published for the subject, keyed by document key, and answers 200 with {} when nothing is. It is always a JSON object, never an array — an empty PHP map would otherwise serialize to [] and break a client that decodes into a dictionary.

Note that 404 covers "not published here" as well as "never existed": a key whose only active version is in a locale you do not serve — and which neither the configured fallback_locale nor, for a mandatory document, default_locale reaches — is not being served to this subject, and the API says so rather than pretending otherwise.

The prefix and middleware are configurable — see routes.api_prefix and routes.api_middleware in the configuration reference.

Way D — no registration form at all (OAuth, SSO, invitations)​

If people sign in through an external provider, there is no registration form to put a checkbox on. The first acceptance then happens in an interstitial: a screen shown after authentication and before first use. Mount the bundled form there and hand it the method for that moment:

<livewire:legal-consent.reconsent-form :method="\Pushery\LegalConsent\Enums\ConsentMethod::FirstUseGate" />

Pick that method rather than one of the others, because the ledger is append-only. RegistrationCheckbox asserts a form that does not exist. ReConsentGate is worse: it asserts an acceptance after a document changed, which never happened — and under an Art. 15 request the subject's history then reads as a re-consent nobody ever asked them for. A wrong entry there cannot be corrected afterwards, because not being correctable is the point of the table.

FirstUseGate names the moment, not the mechanism, so OAuth, SSO, an invitation link and a magic link all use it. Like the re-consent gate, it honors routes.return_to_intended: a subject stopped on the way somewhere is sent back there once everything outstanding is recorded.

Before 0.20.0 this mount rendered nothing

The instruction above is older than the ability to follow it. Mounted with FirstUseGate the form sourced its documents from Consent::outstanding(), which filters on the notice mode of a version change — and a first acceptance is not a change, so a subject who had accepted nothing was shown "everything current, nothing to do".

Since 0.20.0 the mount asks the right question. If you are on an earlier version and built your own interstitial because this one came up empty, that is why — and Consent::firstAcceptance() is now the read your own screen should use.

Both the contract AND the privacy notice come through this screen​

This used to be the section warning you they did not, and the warning was right at the time.

The gate that enforces a change lists only documents in active-re-consent mode, and publishing a privacy notice in that mode is refused outright, for a correct legal reason: a privacy notice is information, and requiring acceptance of it manufactures a consent that was never the legal basis. So an interstitial built on outstanding() collected the contract and never the acknowledgement — quietly, with no entry, nothing red, and a data export that looked complete.

Consent::firstAcceptance() asks a different question — does this subject hold this at all — and the notice mode has no say in it, because nothing was announced and nothing moved. That predicate is wider than "never accepted": somebody who withdrew, declined or terminated holds nothing either, and is asked again rather than served without an agreement. It therefore returns every mandatory document, contract terms and privacy notices alike. One screen, both records, each with ConsentMethod::FirstUseGate.

A voluntary consent is still never in it (Art. 7(4): a consent you can require is not freely given), and an informational page never is either. Those keep their own surfaces.

A middleware of your own asks both questions in one read. Consent::owed($user, firstAcceptance: true) returns what outstanding() and firstAcceptance() return together, each document once, from one read of the subject's ledger. It is the question EnsureLegalConsent asks with gate.first_use on. A screen that records the answers asks the two one at a time, so each acceptance carries the method its own question implies.

The facade​

Or use the facade and manager directly:

use Pushery\LegalConsent\Facades\Consent;
use Pushery\LegalConsent\Support\ConsentContext;
use Pushery\LegalConsent\Enums\ConsentMethod;

Consent::accept($user, 'terms', ConsentContext::forMethod(ConsentMethod::SettingsToggle));
Consent::withdraw($user, 'newsletter', ConsentContext::forMethod(ConsentMethod::SettingsToggle));
$outstanding = Consent::outstanding($user); // documents still owed (see the note below)
$history = Consent::history($user); // Art. 15/20 export payload

Each entry of history() names the version as document_version, and as document_source_version too when the document's source counts its own versions (see Content sources).

Save the subject before you record anything for it. A model that has no key yet has nothing a ledger row could name: no read for that subject would find the row, and no erasure could reach it. Recording a consent for it therefore throws Pushery\LegalConsent\Exceptions\SubjectWithoutKey. Record after save(), or from a created listener.

outstanding() hands back partially loaded documents. It selects the attributes a consent screen needs — key, title, type, version, locale, wording, the tenant, the notice-mode columns and the timestamps — and nothing else, because the alternative is loading the full text of every owed document on a request that only wants to list them. Under Model::shouldBeStrict() an attribute outside that set throws rather than returning null, so reach for Consent::published($key, $locale) when you need the body. The contract's docblock carries the exact list, and Consent::fake() returns models with the same attribute surface — so a test that passes against the fake passes against the real manager.

What a context records​

ConsentContext::fromRequest($request, $method) takes what the request carries, and only part of it comes from the server. The time is the server's, and the IP address is read through your trusted-proxy configuration. The user agent is recorded as the client sent it: context for a reader of the record, not proof. A request id is taken from X-Request-Id only where the server side vouches for it, because your application assigned it after the request arrived or because it came through a trusted proxy. Straight from a client, the header could name any request, so it is left out.

A context is checked against the columns it is written to when it is built, whichever way you build it: source holds 32 characters and requestId 64, ipAddress takes an IPv4 or IPv6 address or null, and userAgent holds 65,535 bytes, which is what a MySQL text column holds. A value that does not fit throws InvalidConsentContext, naming the property, on every engine; written as it was, SQLite would keep it and PostgreSQL or MySQL refuse the write. fromRequest() keeps the first 64 characters of a request id and 1,000 of the user agent, and records an address it cannot read, such as the socket path some setups put in REMOTE_ADDR, as none.

Asking about one subject's standing​

Two more reads, for when you build your own settings screen or need a decision in code rather than a list:

// Does this subject hold the CURRENT major version of one document?
if (! Consent::hasCurrent($user, 'terms')) {
// ...
}

// The same question for several documents at once, for one read instead of one per key.
// The trait method on your own model; a key with no active document answers false, exactly
// as the single-key form does.
$held = $user->hasAcceptedCurrentLegalMany(['terms', 'privacy']);
// ['terms' => true, 'privacy' => false]

// The same question for every registered document, as a map.
$status = Consent::statusFor($user);
// [
// 'terms' => [
// 'key' => 'terms', 'accepted_major' => 1, 'current_major' => 2,
// 'requires_explicit_optin' => false, 'outstanding' => true, 'retired' => false,
// 'pending_confirmation' => false,
// 'accepted_version' => '1.4.0', 'accepted_at' => '2026-03-09T11:20:00+00:00',
// ],
// ...
// ]

accepted_version and accepted_at are the exact version the subject holds and when they took it. Both are null once the holding ends, in step with accepted_major dropping to 0 — this map reports what is held, and a withdrawn opt-in reported as still held is the defect the withdrawal-aware fold exists to prevent. The historical answer lives in history().

pending_confirmation is the double opt-in middle state — requested but not yet confirmed. It is always false in an installation that does not use double opt-in, and reading it is how a screen avoids offering the control to somebody whose confirmation mail is already in their inbox; see what the first row is worth.

hasCurrent() compares major versions, not exact ones: a subject who accepted 2.0.0 still holds the document after 2.1.0 is published, because a minor release is by definition not a material change. It reads the same cross-locale, withdrawal-aware fold the gate uses, so the two can never disagree about one subject.

statusFor() answers that for every registered document at once, and is what a "your agreements" page is built from. Read its outstanding field precisely: it means this subject does not hold the current major version, which is not the same as the gate would stop them. The gate additionally requires an active re-consent whose enforcement date has passed, so a document announced info-only shows as outstanding here and blocks nobody. Use Consent::outstanding($user) when you need the gate's own answer.

Two writes for one person at the same moment​

Every record carries subject_token, the pseudonym that ties a person's consents and delivery proofs together once an erasure has removed their name (Retention). The package keeps it in one row per person and tenant, in the legal_subject_tokens table that migration 000037 creates, and every write takes that row first and reads the token from it. A second write for the same person waits there until the first has committed, so a consent in a request and a notice proof in a queue worker at the same moment carry the same token, also inside a transaction of your own. Without the row each could mint one, and legal-consent:verify-ledger reported the second chain as forged.

Inside a transaction of your own, the row stays taken until your transaction commits, so a transaction that writes consents is best kept short. The row holds two hashes and the token and nothing else. An erasure deletes it, and legal-consent:prune deletes it once no record carries its token any more. Until migration 000037 has run, writes get their token as before, without the row.

Next​