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.
Link the full text with the item's own locale
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:
| Code | Meaning | Retry? |
|---|---|---|
404 unknown_document | there is no such document published here — a typo, or a document unpublished since | no; fix the key |
422 not_withdrawable / not_objectable / not_terminable | the document is real, the transition is not one its legal class can carry | no; the action does not apply |
409 document_changed | a new version was released since the subject was shown the text | yes, 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.
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. UnderModel::shouldBeStrict()an attribute outside that set throws rather than returning null, so reach forConsent::published($key, $locale)when you need the body. The contract's docblock carries the exact list, andConsent::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
- Objections and terminations — the two rights that need their own record.
- Managing legal texts — how to render the full text you linked from the checklist.