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.

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.

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.

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;
}
}

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

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 the configured fallback_locale does not reach — 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.

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

outstanding() hands back partially loaded documents. It selects the attributes a consent screen needs — key, title, type, version, locale, wording, 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.

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.

Next