Install, configure, and apply the Legal Consent for Laravel package in a Laravel application — versioned legal texts, an append-only acceptance ledger, and a re-consent gate.
php artisan boost:add-skill pushery/legal-consent-for-laravel
Save this content to: .claude/skills/legal-consent-for-laravel/SKILL.md
# Legal Consent for Laravel
Use this skill when a Laravel application installs, configures or integrates the
`pushery/legal-consent-for-laravel` package.
## Primary Goal
Make the application able to prove, per subject, **which version of which legal text that subject
actually saw and accepted** — and to enforce a fresh acceptance when a material change ships. The
proof artifact is the product: the page and the ledger must render one text, not two that are kept
in sync by hand.
## Workflow
### 1. Install
```bash
composer require pushery/legal-consent-for-laravel
```
The service provider is registered through package discovery.
Your user model can be keyed however you key it — the ledger stores `subject_id` as a
64-character string, so an auto-increment id, a UUID and a ULID all fit and `HasUuids` needs no
adapter. Do NOT add a cast or a config option for it; there is nothing to configure.
### 2. Configure
```bash
php artisan vendor:publish --tag=legal-consent-config
php artisan vendor:publish --tag=legal-consent-lang
php artisan migrate
```
There is an umbrella tag, `--tag=legal-consent`, which adds the migrations
(`legal-consent-migrations`), the views (`legal-consent-views`) and the script of the document
dialog (`legal-consent-assets`, copied to `public/vendor/legal-consent`) to those two.
**Do NOT reach for it on an application that has WireKit installed.** It copies the whole view
tree into `resources/views/vendor/legal-consent/`, whose top level is the plain stubs, and a
published view is resolved before either of the package's own sets — so the umbrella publish
silently turns `ui.variant => 'auto'` into plain, unstyled consent screens. Nothing errors and
`legal-consent:doctor` cannot see it. If views are already published on such an app, run
`php artisan vendor:publish --tag=legal-consent-wirekit --force` to pull the WireKit twins over
the published copies (`--force` is required; the files exist).
The document dialog needs its script on the page. Publish it on its own with
`php artisan vendor:publish --tag=legal-consent-assets` and load it before Alpine:
```blade
<script src="{{ asset('vendor/legal-consent/legal-consent.js') }}" defer></script>
```
The file in `public/` is a copy that Composer does not update, so add
`@php artisan vendor:publish --tag=legal-consent-assets --force` to the app's `post-update-cmd`
scripts. To brand the change-notice mail without copying the consent screens, publish
`legal-consent-mail` (the notice view and its theme); the views tag carries the same two files.
Every option in `config/legal-consent.php` is documented inline. The ones that usually matter first:
- `documents` — the registry of keys the app uses (`terms`, `privacy`, …) and their legal basis:
`contract`, `acknowledgement`, `consent`, or `informational`. The last one is for a page you
must publish but nobody agrees to — an Impressum, a cookie policy, an accessibility statement.
It uses the same editor and publishing as the rest and never touches registration, the gate or
notices, so do NOT build a separate renderer for those pages. Its `ui_wording` is `null` — it
asks the reader for nothing — so guard the wording if your own view renders it.
Per document, `'ask_at_registration' => false` takes a document off the SIGN-UP form without
changing anything else — it still gates, so the subject meets it at the re-consent screen. Use
it for something acknowledged later in-app; use `informational` for a page that binds nobody.
Per document, `'concluded_by' => App\Models\Workspace::class` names the model that concludes
it, such as a data processing agreement a workspace signs. No person is asked for it, not at
sign-up and not by the gate. Accept it with `Consent::accept($workspace, 'dpa', $context)`, and
give that model `Notifiable` with a mail route, because its change notices go to the workspace.
- `document_url` — an invokable class-string with `__invoke(LegalDocument $document): ?string`.
**Set this.** Without it every consent surface shows the document's title as dead text: the
registration checkboxes, the re-consent gate and "Your consents". Resolve from
`$document->locale`, never the app locale — a mandatory document may be published only in the
default language, and a link built from the page's locale points at nothing. Left null, nothing
breaks; the titles simply are not links.
A closure works too, but do not deploy one: `php artisan config:cache` cannot serialize it and
aborts the whole cache. Nothing before the deploy reproduces that, so use the class-string. The
same applies to `gate.subject_filter`, and `legal-consent:doctor` reports either one.
- `routes.consent_name` — the route the enforcement middleware sends a blocked subject to.
- `routes.web` — off by default. Switch it on if you render the plain settings stub without
Livewire: it registers `POST {web_prefix}/consent/withdraw` behind `['web', 'auth']`, and the
presenter then fills each held consent's `withdraw_url`. With it off the stub renders no
withdraw form at all, which is deliberate — a form with no action is the appearance of a
control, not a control. Not needed with the Livewire component, which calls its own action.
- `routes.web_throttle` — `'60,1'` by default: one rate limit, keyed on the subject, in front of
every session-backed ledger write — that route and the Livewire components' grant, withdraw,
object and terminate actions. Same shape as `routes.api_throttle`; `null` switches it off.
- `routes.api` — off by default. The headless JSON API under `{api_prefix}`, `legal` by default,
behind `routes.api_middleware` and `routes.api_throttle`. An application that keeps what machines
call on a host of its own (`api.<domain>`) sets `routes.api_domain`, or
`LEGAL_CONSENT_API_DOMAIN`, and the API answers on that host only.
- `ui.variant` — `auto` by default: the WireKit-native views are served when `pushery/wirekit`
≥ 2.68.0 is installed, the plain ones otherwise. Pin `plain` or `wirekit` to decide it yourself.
- `notice_mail` — the change-notice mail. `identity.declarant` names the declaring legal person
(§ 126b BGB) and is appended to the notice AND to its append-only proof row; leave it null and
the notice is byte-for-byte what it was. Multi-tenant apps bind `ResolvesNoticeIdentity` instead
— one global declarant names the wrong legal person in every tenant but one.
- `retention_after_end` + `schedule.prune` — retention is a statement until the sweep is switched on.
`LEGAL_CONSENT_RETENTION_AFTER_END` sets it without publishing the config, as
`LEGAL_CONSENT_DOUBLE_OPT_IN_CONFIRM_WITHIN` sets `double_opt_in.confirm_within`.
### 3. Apply the package
**Author the texts.** Markdown with frontmatter at `resources/legal/{type}/{locale}.md`. The
frontmatter carries the `version` (`MAJOR.MINOR.PATCH`) — always set one; a document without a
version can never gate.
**Publish a version.** Publishing freezes the rendered, sanitized bytes and their hash into an
append-only row:
```bash
# locale is a positional argument, and exactly one notice-mode flag is required:
php artisan legal-consent:publish terms de --active # re-consent required
php artisan legal-consent:publish terms de --editorial # silent, no notice
php artisan legal-consent:publish --all --editorial # the whole matrix, idempotent
php artisan legal-consent:publish --all --only-missing --editorial # gap-fill only — use THIS in a deploy script
php artisan legal-consent:publish --all --dry-run --editorial # what would a run do? writes nothing
php artisan legal-consent:publish privacy --locales=de,en --info # every language together: how a version changes its mode
php artisan legal-consent:status # every document by language, how far it is, and the command that publishes it
php artisan legal-consent:review terms --locales=de,en --by="Jane Doe" # the drafts store: sign drafts off from the shell
php artisan legal-consent:check-drift # source changed since it was published?
php artisan legal-consent:verify-documents --placeholders-fail # before a launch: fails on a placeholder text or a 0.0.0 version
php artisan legal-consent:publish terms de --active --tenant=acme # tenancy on: the console resolves no tenant, so name it
```
The mode is the legal classification of the change, so it is never guessed: `--active` (the subject
must accept again), `--deemed` (silence counts, contract terms only), `--info` (announced, takes
effect regardless), `--editorial` (no material change). `--active` needs a new major version: the
gate compares major versions, so the publisher refuses it on a minor or patch bump of a contract or
a consent. A document's first publication is not a bump and is unaffected. A later version inside a
major keeps what the major asked for: an `--editorial` fix after an `--active` major still gates
everyone who has not accepted it, and a correction of an `--info` or `--deemed` change keeps its
notices and its objection window running.
**Four commands work on what is already published:**
```bash
php artisan legal-consent:rerender # check-drift reports "PRESENTATION only": the same text again, next patch, silent mode
php artisan legal-consent:changes terms de # print the pending change description of a key and locale; --clear discards it
php artisan legal-consent:cache-flush # after a config or cache-driver change; a content change clears the cache by itself
php artisan legal-consent:verify-ledger # with tamper_evidence on: a broken hash chain exits non-zero, for CI or a scheduled audit
```
`legal-consent:rerender` refuses a document whose text has moved, so it never re-publishes a real
change under the silent mode. A change description is written through the facade, where it can be
reviewed in a diff; `legal-consent:changes` only reads or discards it. With tenancy on,
`changes` works in the shared bucket unless `--tenant=` names a tenant, while `rerender` and
`cache-flush` cover every tenant without it.
**Tenancy is opt-in** (`tenancy.enabled`). The resolver returns the tenant's key as an int or a
string, or `null` outside a tenant, and any other answer throws `UnresolvableTenant`, so return
`tenant()?->getKey()`, never the model:
```php
app(\Pushery\LegalConsent\Support\TenantContext::class)->resolveUsing(fn () => auth()->user()?->tenant_id);
```
**A fresh install has published nothing, and nothing says so.** `legal_documents` is empty after
`migrate`, `Consent::published()` returns `null` for every document, and the read path does not
fall back to the source — so every legal page renders empty with no error and no log. Run
`legal-consent:publish --all --editorial` once, or ask `legal-consent:doctor`, which says so when
nothing is published and names every missing language once something is. A document on the drafts
store publishes nothing before its text is written and released on the admin screen. Texts already
written as Markdown reach that screen with `php artisan legal-consent:draft-from-source`, plus
`--reviewed` when they were reviewed in the repository. Do NOT write a migration or a command of
your own for it.
**When the application deletes an account, call `Consent::forget($user)`.** It strips
`subject_type`, `subject_id`, `ip_address`, `user_agent` and `request_id` from the consent ledger,
and `subject_type` and `subject_id` from the notice ledger — the other three are not columns there.
It keeps everything that proves the consent, including the `subject_token` pseudonym that still ties
the two together (Art. 17(3)(b)/(e)). It returns counts per ledger.
Do NOT write this by hand and do NOT try to clear those columns with an update — both ledgers
refuse every `UPDATE`, at the model and at a database trigger. A hand-rolled delete-and-reinsert
also breaks the tamper chain: the erased columns are inputs to the row hash, and a row's hash folds
in its own link, so the rewrite has to re-link everything after it. `Consent::forget()` does that;
a consumer version almost certainly does not.
In tests, `Consent::fake()` records the call — assert it with `assertForgotten($user)` and
`assertNotForgotten($other)` rather than migrating these tables into your test database.
**A missing markdown file still fails `--only-missing`.** Only a source waiting on an author is
warned about and skipped — the bundled draft source, or your own if it implements
`Pushery\LegalConsent\Content\AwaitsAuthoring`. Everything else counts as provisioned, so a
deployment missing a legal text goes red instead of leaving an empty page behind a green deploy.
Do NOT add a flag for this; it follows from the source.
**In the deploy script, use `--all --only-missing --editorial`, not the bare `--all`.** Re-running
`--all` changes nothing only while the sources are unchanged. Once a text is edited and its version
bumped, the next deploy publishes it as `editorial` — the one notice mode that tells nobody, chosen
by a script instead of a person. `--only-missing` never reads a combination that already has an
active version, so it cannot classify a change. That is the whole of the difference — a source with
no text yet is a named skip in both invocations, because whether an empty source is a defect is a
property of the source and not of the flag.
**Enforce re-consent.** Add the middleware to the routes that require an accepted contract:
```php
Route::middleware(['auth', 'legal.consent'])->group(function (): void {
// ...
});
```
A browser request is redirected to the consent route; a JSON request gets `409
legal_consent_required` with the outstanding document keys.
**Render the frozen text, never the source.** The public `/terms` page must read the published row,
so `hash(page) === ledger.content_hash` is a tautology rather than a chore:
```php
$document = Consent::published('terms', app()->getLocale()); // null when unpublished, never throws
```
A form that only shows the acceptance sentence beside a link, on every page, reads
`Consent::publishedVersion($key, $locale)` instead: the same version without its text, from the
cache the consent gate reads, with `uiWording`, `acceptanceFingerprint()` and, under multi-tenancy,
`tenantId`.
**Collect acceptance at registration** from what is actually published, instead of a hardcoded list:
```php
foreach (Consent::registrationChecklist(app()->getLocale()) as $item) {
// $item->field() is the input name — never build it yourself. A document control is
// `legal_{key}`, but the Art. 8 age attestation is named by its key alone, so a
// hand-built `legal_{$item->key}` renders a required box that can never be satisfied.
// $item->title, $item->wording, $item->required, $item->url
}
```
**Double opt-in?** Two rows, and only the second is a consent:
```php
Consent::requestConfirmation($user, 'newsletter', $context); // action: optin_requested — NOT held
Consent::confirm($user, 'newsletter', $context); // action: confirmed — now held
```
The unconfirmed row does not raise `accepted_major` and `hasCurrent()` stays false;
`statusFor()[$key]['pending_confirmation']` is how a screen shows that middle state instead of
offering the control again. The sibling flag `retired` says the document has no active version any
more: the holding stays, its withdrawal control stays, and it is never `outstanding` — retiring a
document must not close the door on Art. 7(3). Listen for `ConsentConfirmationRequested` to send the mail — the
package owns the ledger, not the mailbox — and never for `ConsentRecorded`, which does not fire
for a request.
**Record it** through the manager or facade, passing the hash the subject was shown so a version
released mid-session cannot be frozen against them:
```php
Consent::accept($user, 'terms', ConsentContext::forMethod(ConsentMethod::RegistrationCheckbox), $locale, $shownHash);
```
**No registration form (OAuth, SSO, invitations)?** With the `Registered` listener on, the callback
writes an acceptance row for every mandatory document without a human having done anything. The
recorder logs that; set `registration.without_form_fields => 'refuse'` and it raises
`UnevidencedConsentException` and records nothing instead. Better still, capture the first
acceptance in an interstitial shown after authentication and before first use, and use
`ConsentMethod::FirstUseGate` for it:
```blade
<livewire:legal-consent.reconsent-form :method="\Pushery\LegalConsent\Enums\ConsentMethod::FirstUseGate" />
```
**Drop in the optional UI** (needs `livewire/livewire`. The WireKit-native views are served
automatically when `pushery/wirekit` ≥ 2.68.0 is installed — `legal-consent.ui.variant` defaults to
`auto`; publish `legal-consent-wirekit` only to customize them. The floor is part of the automatic
choice rather than advice because the views name components that must exist: WireKit's own localized
screen-reader strings landed in 2.26.0, the busy-state props these stubs use landed in 2.47.0, and
the release dialog's close on confirm landed in 2.49.0, and the quiet new-tab link and the editor
that reports only a person's edit landed in 2.56.0; 2.68.0 is the oldest release the package's
own suite installs):
```blade
<livewire:legal-consent.reconsent-form />
<livewire:legal-consent.consent-settings />
```
Each settings row carries `outstanding` — true when the subject holds an **older major** than the
one that is live, which is the state the gate will otherwise compel. It is computed exactly as
`Consent::statusFor()` computes it, and a voluntary consent is never outstanding (Art. 7(4)). The
shipped views render it as **Action required**; if you render your own, read that key.
Both screens expose `object` and `terminate` besides their own action. **Every public method of an
embedded Livewire component is reachable whether or not the template renders a button for it**, so
if your product has no answer to those two, switch them off at the embed rather than deleting
buttons:
```blade
<livewire:legal-consent.consent-settings :allow-objection="false" :allow-termination="false" />
<livewire:legal-consent.reconsent-form :allow-objection="false" :allow-termination="false" />
```
A transition that does not apply answers `404`, not `500` — a key nothing published, a key
unpublished between the render and the click, or an objection against a consent (which is withdrawn,
not objected to). The reason rides on the exception and reaches neither the page nor your log
(Laravel never reports a `NotFoundHttpException`), so report `404`s yourself if you want to see
them.
If you call the manager or the facade directly instead of through the component, catch these
yourself — all in `Pushery\LegalConsent\Exceptions`: `LegalDocumentNotFound`,
`NotWithdrawableException`, `NotObjectableException`, `NotTerminableException`,
`NotConsentBearingException`. They are refusals to record a row the ledger cannot take back, not
failures to work around.
Save a model before you record a consent for it. A subject without a key throws
`SubjectWithoutKey`, because a row that names nobody is found by no read and reached by no erasure.
Record after `save()` or in a `created` listener.
The last one catches the case that is easiest to reach by accident: an `informational` document —
an Impressum, a cookie policy — is published so it can be read and asks the reader for nothing, so
`Consent::accept()` and `Consent::record()` refuse it. The JSON API answers `422` with an `error`
of `not_consent_bearing`. Do not offer such a document as something to accept; render it, and let
the gate ignore it.
`Consent::record()` also refuses an action the document's type cannot carry, with
`IncompatibleConsentActionException`. Contract terms and privacy notices are `Acknowledged` (or
`ReAccepted` after a material change); `Granted`, `Withdrawn`, `Declined`, `OptInRequested` and
`Confirmed` belong to documents with `requires_explicit_optin`, and a `Confirmed` additionally needs
a preceding `OptInRequested` row for the same subject and key. The named transitions —
`accept()`, `withdraw()`, `object()`, `terminate()`, `confirm()` — ask their own half already, so
prefer them over `record()` unless you genuinely need the untyped write. The ledger is append-only,
so a row asserting a state the law has no shape for can never be corrected.
**No registration form to put a checkbox on?** Sign-in through an external provider raises
`Registered` with no form, so there is nothing to validate a tick against. Show an interstitial
after authentication and mount the bundled form as a first-use gate:
```blade
<livewire:legal-consent.reconsent-form
:method="\Pushery\LegalConsent\Enums\ConsentMethod::FirstUseGate" />
```
It lists every mandatory document the subject does not hold — `Consent::firstAcceptance()` is the
same read if you build your own screen — and records each with that method. "Does not hold" is
wider than "never accepted": somebody who withdrew, declined or terminated holds nothing either,
and is asked again rather than served without an agreement. Do not reach for
`ReConsentGate` there: the ledger is append-only, and that value asserts an acceptance *after a
document changed*, which never happened.
`Consent::outstanding()` answers a different question and will not do: it filters on the notice
mode of a version change, and a first acceptance is not a change.
Set `legal-consent.gate.first_use` to `true` to have the middleware stop people until they have
been through that screen — and only together with the screen, or they land on a consent route that
tells them nothing is due.
A middleware of your own asks the same question as the package's with
`Consent::owed($user, firstAcceptance: true)`: both sets, each document once, from one read of the
ledger.
Asking about several documents at once costs one read of the ledger rather than one per key:
```php
$held = $user->hasAcceptedCurrentLegalMany(['terms', 'privacy']);
// ['terms' => true, 'privacy' => false]
```
**Alert on the scheduled sweeps.** Bind `Pushery\LegalConsent\Contracts\LegalConsentMonitor` (the
default binding discards everything) and each sweep calls `heartbeat(string $task, int $processed)`.
Three task names are the ordinary beat — `legal-consent:prune`, `legal-consent:dispatch-notices`,
`legal-consent:close-objection-windows` — and seven are sent only when a run failed:
- `legal-consent:dispatch-notices.deficient` — a version went out without its mandatory notice
content. § 308 Nr. 5 lit. b makes the silence warning a validity condition, so silence cannot bind
against those notices at all: the wording has to be fixed and the notice re-sent.
- `legal-consent:dispatch-notices.held` — a notice still owed because the audience exceeded
`notifications.max_recipients_per_run`.
- `legal-consent:dispatch-notices.canceled` — a notice still owed because a `NoticeDispatching`
listener held it back. It goes out on the first run the listener lets through; `--force` does not
reach a listener.
- `legal-consent:dispatch-notices.unreachable` — subjects whose notice failed
`notifications.max_attempts` times. It is still owed: correct the address and run
`legal-consent:renotify`, or erase the subject.
- `legal-consent:dispatch-notices.expired` — a deemed-consent version still owed its notice after
the objection deadline had passed. Such a notice can found no acceptance, so nothing is sent: the
subjects it did not reach need a new version with a new objection deadline.
- `legal-consent:close-objection-windows.unproved` — subjects that could not be deemed for want of a
§ 308 Nr. 5 lit. b warning delivered by the objection deadline.
- `legal-consent:close-objection-windows.late` — subjects whose notice arrived only after the
objection deadline, which left them no period to object; they need a new version.
**Alert on those seven by name.** The ordinary heartbeat is sent BEFORE the failure branch, so a run
that held a legally required notice back still beats as usual with the count it managed.
`legal-consent:prune` is the exception: it beats last, after it has re-linked the tamper chain it
broke, so a sweep that dies anywhere, the repair included, sends no beat.
## Testing your own app against it
`Consent::fake()` swaps the manager for an in-memory double — no database, no migrations of this
package's tables into your test schema.
```php
$fake = Consent::fake();
$this->post('/register', [...]);
$fake->assertAccepted($user, 'terms');
```
Reads default to a fully-consented subject (nothing outstanding, `hasCurrent()` true, nothing
published), so a test about something else is never blocked by a gate it did not mention. Declare
what you care about with `owes($user, 'terms')`, `publishes($document)`, `checklistIs(...)`,
`statusIs([...])` or `historyIs([...])`.
Writes are recorded, not performed: the returned `LegalConsent` carries the attributes but
`exists` stays false. Assertions: `assertRecorded`, `assertNotRecorded`, `assertAccepted`,
`assertWithdrawn`, `assertNothingRecorded`, `assertRecordedCount`, plus `recorded()` for anything
else. `assertAccepted` matches `granted`, `acknowledged` and `re_accepted` — never
`deemed_accepted`, because silence is not an act of the subject.
## Examples
A minimal, complete adoption: publish the config, author `resources/legal/terms/de.md` with
`version: 1.0.0`, run `legal-consent:publish terms de --active`, put `legal.consent` on the
authenticated route group, render `Consent::published('terms', 'de')?->html` on the public page, and
embed `<livewire:legal-consent.reconsent-form />` on the consent route. Make sure `schedule:run`
is wired up — **do NOT add a schedule entry for `legal-consent:dispatch-notices`**, the package
registers it itself, hourly, behind `withoutOverlapping()` and `onOneServer()`. A second entry runs
outside that mutex, and because the watermark is only stamped after a send, the result is a
duplicate legal-change notification per server. Put `legal-consent:publish --all --editorial` in
the deploy script so a fresh database is never left with empty legal pages.
The public page renders the stored bytes **unescaped**, which is the one place in this integration
where that is the right call:
```blade
{{-- $document came from Consent::published('terms', app()->getLocale()) in the route --}}
@if ($document !== null)
<h1>{{ $document->title }}</h1>
{!! $document->html !!}
@endif
```
`html` is exactly what the package sanitized before it froze the row, and the hash the ledger
carries is the hash of those bytes. So `{{ }}` there is not the safe choice, it is the wrong one —
the page shows visible tags — and sanitizing the value a second time changes the bytes, which
separates the page from the proof. Everything else on a consent screen is escaped as usual.
## Anti-Patterns
- **Do not render the source text on the public page.** Render the published row. Rendering the
source lets the page and the ledger drift with no test turning red — the proof breaks silently.
- **Do not add a second sanitizer or hasher.** The package sanitizes and hashes in one place; a
second one means the ledger hashes one form while the page renders another.
- **Do not copy the sanitizer's allowlist into a test.** A rich editor drops a tag it does not know
while parsing, and the next save stores the loss. Test the editor against
`LegalHtmlSanitizer::allowedElements()` and `allowedAttributes()`, which grow with the package.
- **Do not hardcode registration checkboxes.** Derive them from `registrationChecklist()`, or the
form will block on a document that does not exist, or quietly omit one that does.
- **Do not edit `legal_documents` rows.** They are append-only and the database refuses it; publish a
new version instead.
- **Do not publish `legal-consent-users-cache` or `legal-consent-backfill` casually.** One drops a
column from your `users` table, the other backfills history — both are deliberate, separate steps.
- **Do not allowlist Livewire's endpoint path in `middleware.allowlist_paths`.** The gate resolves
it from the installation already. Livewire 4 derives the prefix from `APP_KEY`, so writing your
own `/livewire-<hash>/*` is green in development and silently wrong in production.
- **Do not leave `document_url` unset if your app has legal pages.** Every consent surface then
shows a title the subject cannot open — including the re-consent gate, where they cannot continue
until they agree.
- **Do not ship a closure in `document_url` or `gate.subject_filter`.** Both accept one, and both
break `php artisan config:cache` — a failure that first appears in the deploy, because nothing
local caches. Use an invokable class-string; `legal-consent:doctor` names the offenders.
Use this skill when a Laravel application installs, configures or integrates the
pushery/legal-consent-for-laravel package.
Make the application able to prove, per subject, which version of which legal text that subject actually saw and accepted — and to enforce a fresh acceptance when a material change ships. The proof artifact is the product: the page and the ledger must render one text, not two that are kept in sync by hand.
composer require pushery/legal-consent-for-laravel
The service provider is registered through package discovery.
Your user model can be keyed however you key it — the ledger stores subject_id as a
64-character string, so an auto-increment id, a UUID and a ULID all fit and HasUuids needs no
adapter. Do NOT add a cast or a config option for it; there is nothing to configure.
php artisan vendor:publish --tag=legal-consent-config
php artisan vendor:publish --tag=legal-consent-lang
php artisan migrate
There is an umbrella tag, --tag=legal-consent, which adds the migrations
(legal-consent-migrations), the views (legal-consent-views) and the script of the document
dialog (legal-consent-assets, copied to public/vendor/legal-consent) to those two.
Do NOT reach for it on an application that has WireKit installed. It copies the whole view
tree into resources/views/vendor/legal-consent/, whose top level is the plain stubs, and a
published view is resolved before either of the package's own sets — so the umbrella publish
silently turns ui.variant => 'auto' into plain, unstyled consent screens. Nothing errors and
legal-consent:doctor cannot see it. If views are already published on such an app, run
php artisan vendor:publish --tag=legal-consent-wirekit --force to pull the WireKit twins over
the published copies (--force is required; the files exist).
The document dialog needs its script on the page. Publish it on its own with
php artisan vendor:publish --tag=legal-consent-assets and load it before Alpine:
<script src="{{ asset('vendor/legal-consent/legal-consent.js') }}" defer></script>
The file in public/ is a copy that Composer does not update, so add
@php artisan vendor:publish --tag=legal-consent-assets --force to the app's post-update-cmd
scripts. To brand the change-notice mail without copying the consent screens, publish
legal-consent-mail (the notice view and its theme); the views tag carries the same two files.
Every option in config/legal-consent.php is documented inline. The ones that usually matter first:
documents — the registry of keys the app uses (terms, privacy, …) and their legal basis:
contract, acknowledgement, consent, or informational. The last one is for a page you
must publish but nobody agrees to — an Impressum, a cookie policy, an accessibility statement.
It uses the same editor and publishing as the rest and never touches registration, the gate or
notices, so do NOT build a separate renderer for those pages. Its ui_wording is null — it
asks the reader for nothing — so guard the wording if your own view renders it.
Per document, 'ask_at_registration' => false takes a document off the SIGN-UP form without
changing anything else — it still gates, so the subject meets it at the re-consent screen. Use
it for something acknowledged later in-app; use informational for a page that binds nobody.
Per document, 'concluded_by' => App\Models\Workspace::class names the model that concludes
it, such as a data processing agreement a workspace signs. No person is asked for it, not at
sign-up and not by the gate. Accept it with Consent::accept($workspace, 'dpa', $context), and
give that model Notifiable with a mail route, because its change notices go to the workspace.document_url — an invokable class-string with __invoke(LegalDocument $document): ?string.
Set this. Without it every consent surface shows the document's title as dead text: the
registration checkboxes, the re-consent gate and "Your consents". Resolve from
$document->locale, never the app locale — a mandatory document may be published only in the
default language, and a link built from the page's locale points at nothing. Left null, nothing
breaks; the titles simply are not links.A closure works too, but do not deploy one: php artisan config:cache cannot serialize it and
aborts the whole cache. Nothing before the deploy reproduces that, so use the class-string. The
same applies to gate.subject_filter, and legal-consent:doctor reports either one.
routes.consent_name — the route the enforcement middleware sends a blocked subject to.routes.web — off by default. Switch it on if you render the plain settings stub without
Livewire: it registers POST {web_prefix}/consent/withdraw behind ['web', 'auth'], and the
presenter then fills each held consent's withdraw_url. With it off the stub renders no
withdraw form at all, which is deliberate — a form with no action is the appearance of a
control, not a control. Not needed with the Livewire component, which calls its own action.routes.web_throttle — '60,1' by default: one rate limit, keyed on the subject, in front of
every session-backed ledger write — that route and the Livewire components' grant, withdraw,
object and terminate actions. Same shape as routes.api_throttle; null switches it off.routes.api — off by default. The headless JSON API under {api_prefix}, legal by default,
behind routes.api_middleware and routes.api_throttle. An application that keeps what machines
call on a host of its own (api.<domain>) sets routes.api_domain, or
LEGAL_CONSENT_API_DOMAIN, and the API answers on that host only.ui.variant — auto by default: the WireKit-native views are served when pushery/wirekit
≥ 2.68.0 is installed, the plain ones otherwise. Pin plain or wirekit to decide it yourself.notice_mail — the change-notice mail. identity.declarant names the declaring legal person
(§ 126b BGB) and is appended to the notice AND to its append-only proof row; leave it null and
the notice is byte-for-byte what it was. Multi-tenant apps bind ResolvesNoticeIdentity instead
— one global declarant names the wrong legal person in every tenant but one.retention_after_end + schedule.prune — retention is a statement until the sweep is switched on.
LEGAL_CONSENT_RETENTION_AFTER_END sets it without publishing the config, as
LEGAL_CONSENT_DOUBLE_OPT_IN_CONFIRM_WITHIN sets double_opt_in.confirm_within.Author the texts. Markdown with frontmatter at resources/legal/{type}/{locale}.md. The
frontmatter carries the version (MAJOR.MINOR.PATCH) — always set one; a document without a
version can never gate.
Publish a version. Publishing freezes the rendered, sanitized bytes and their hash into an append-only row:
# locale is a positional argument, and exactly one notice-mode flag is required:
php artisan legal-consent:publish terms de --active # re-consent required
php artisan legal-consent:publish terms de --editorial # silent, no notice
php artisan legal-consent:publish --all --editorial # the whole matrix, idempotent
php artisan legal-consent:publish --all --only-missing --editorial # gap-fill only — use THIS in a deploy script
php artisan legal-consent:publish --all --dry-run --editorial # what would a run do? writes nothing
php artisan legal-consent:publish privacy --locales=de,en --info # every language together: how a version changes its mode
php artisan legal-consent:status # every document by language, how far it is, and the command that publishes it
php artisan legal-consent:review terms --locales=de,en --by="Jane Doe" # the drafts store: sign drafts off from the shell
php artisan legal-consent:check-drift # source changed since it was published?
php artisan legal-consent:verify-documents --placeholders-fail # before a launch: fails on a placeholder text or a 0.0.0 version
php artisan legal-consent:publish terms de --active --tenant=acme # tenancy on: the console resolves no tenant, so name it
The mode is the legal classification of the change, so it is never guessed: --active (the subject
must accept again), --deemed (silence counts, contract terms only), --info (announced, takes
effect regardless), --editorial (no material change). --active needs a new major version: the
gate compares major versions, so the publisher refuses it on a minor or patch bump of a contract or
a consent. A document's first publication is not a bump and is unaffected. A later version inside a
major keeps what the major asked for: an --editorial fix after an --active major still gates
everyone who has not accepted it, and a correction of an --info or --deemed change keeps its
notices and its objection window running.
Four commands work on what is already published:
php artisan legal-consent:rerender # check-drift reports "PRESENTATION only": the same text again, next patch, silent mode
php artisan legal-consent:changes terms de # print the pending change description of a key and locale; --clear discards it
php artisan legal-consent:cache-flush # after a config or cache-driver change; a content change clears the cache by itself
php artisan legal-consent:verify-ledger # with tamper_evidence on: a broken hash chain exits non-zero, for CI or a scheduled audit
legal-consent:rerender refuses a document whose text has moved, so it never re-publishes a real
change under the silent mode. A change description is written through the facade, where it can be
reviewed in a diff; legal-consent:changes only reads or discards it. With tenancy on,
changes works in the shared bucket unless --tenant= names a tenant, while rerender and
cache-flush cover every tenant without it.
Tenancy is opt-in (tenancy.enabled). The resolver returns the tenant's key as an int or a
string, or null outside a tenant, and any other answer throws UnresolvableTenant, so return
tenant()?->getKey(), never the model:
app(\Pushery\LegalConsent\Support\TenantContext::class)->resolveUsing(fn () => auth()->user()?->tenant_id);
A fresh install has published nothing, and nothing says so. legal_documents is empty after
migrate, Consent::published() returns null for every document, and the read path does not
fall back to the source — so every legal page renders empty with no error and no log. Run
legal-consent:publish --all --editorial once, or ask legal-consent:doctor, which says so when
nothing is published and names every missing language once something is. A document on the drafts
store publishes nothing before its text is written and released on the admin screen. Texts already
written as Markdown reach that screen with php artisan legal-consent:draft-from-source, plus
--reviewed when they were reviewed in the repository. Do NOT write a migration or a command of
your own for it.
When the application deletes an account, call Consent::forget($user). It strips
subject_type, subject_id, ip_address, user_agent and request_id from the consent ledger,
and subject_type and subject_id from the notice ledger — the other three are not columns there.
It keeps everything that proves the consent, including the subject_token pseudonym that still ties
the two together (Art. 17(3)(b)/(e)). It returns counts per ledger.
Do NOT write this by hand and do NOT try to clear those columns with an update — both ledgers
refuse every UPDATE, at the model and at a database trigger. A hand-rolled delete-and-reinsert
also breaks the tamper chain: the erased columns are inputs to the row hash, and a row's hash folds
in its own link, so the rewrite has to re-link everything after it. Consent::forget() does that;
a consumer version almost certainly does not.
In tests, Consent::fake() records the call — assert it with assertForgotten($user) and
assertNotForgotten($other) rather than migrating these tables into your test database.
A missing markdown file still fails --only-missing. Only a source waiting on an author is
warned about and skipped — the bundled draft source, or your own if it implements
Pushery\LegalConsent\Content\AwaitsAuthoring. Everything else counts as provisioned, so a
deployment missing a legal text goes red instead of leaving an empty page behind a green deploy.
Do NOT add a flag for this; it follows from the source.
In the deploy script, use --all --only-missing --editorial, not the bare --all. Re-running
--all changes nothing only while the sources are unchanged. Once a text is edited and its version
bumped, the next deploy publishes it as editorial — the one notice mode that tells nobody, chosen
by a script instead of a person. --only-missing never reads a combination that already has an
active version, so it cannot classify a change. That is the whole of the difference — a source with
no text yet is a named skip in both invocations, because whether an empty source is a defect is a
property of the source and not of the flag.
Enforce re-consent. Add the middleware to the routes that require an accepted contract:
Route::middleware(['auth', 'legal.consent'])->group(function (): void {
// ...
});
A browser request is redirected to the consent route; a JSON request gets 409 legal_consent_required with the outstanding document keys.
Render the frozen text, never the source. The public /terms page must read the published row,
so hash(page) === ledger.content_hash is a tautology rather than a chore:
$document = Consent::published('terms', app()->getLocale()); // null when unpublished, never throws
A form that only shows the acceptance sentence beside a link, on every page, reads
Consent::publishedVersion($key, $locale) instead: the same version without its text, from the
cache the consent gate reads, with uiWording, acceptanceFingerprint() and, under multi-tenancy,
tenantId.
Collect acceptance at registration from what is actually published, instead of a hardcoded list:
foreach (Consent::registrationChecklist(app()->getLocale()) as $item) {
// $item->field() is the input name — never build it yourself. A document control is
// `legal_{key}`, but the Art. 8 age attestation is named by its key alone, so a
// hand-built `legal_{$item->key}` renders a required box that can never be satisfied.
// $item->title, $item->wording, $item->required, $item->url
}
Double opt-in? Two rows, and only the second is a consent:
Consent::requestConfirmation($user, 'newsletter', $context); // action: optin_requested — NOT held
Consent::confirm($user, 'newsletter', $context); // action: confirmed — now held
The unconfirmed row does not raise accepted_major and hasCurrent() stays false;
statusFor()[$key]['pending_confirmation'] is how a screen shows that middle state instead of
offering the control again. The sibling flag retired says the document has no active version any
more: the holding stays, its withdrawal control stays, and it is never outstanding — retiring a
document must not close the door on Art. 7(3). Listen for ConsentConfirmationRequested to send the mail — the
package owns the ledger, not the mailbox — and never for ConsentRecorded, which does not fire
for a request.
Record it through the manager or facade, passing the hash the subject was shown so a version released mid-session cannot be frozen against them:
Consent::accept($user, 'terms', ConsentContext::forMethod(ConsentMethod::RegistrationCheckbox), $locale, $shownHash);
No registration form (OAuth, SSO, invitations)? With the Registered listener on, the callback
writes an acceptance row for every mandatory document without a human having done anything. The
recorder logs that; set registration.without_form_fields => 'refuse' and it raises
UnevidencedConsentException and records nothing instead. Better still, capture the first
acceptance in an interstitial shown after authentication and before first use, and use
ConsentMethod::FirstUseGate for it:
<livewire:legal-consent.reconsent-form :method="\Pushery\LegalConsent\Enums\ConsentMethod::FirstUseGate" />
Drop in the optional UI (needs livewire/livewire. The WireKit-native views are served
automatically when pushery/wirekit ≥ 2.68.0 is installed — legal-consent.ui.variant defaults to
auto; publish legal-consent-wirekit only to customize them. The floor is part of the automatic
choice rather than advice because the views name components that must exist: WireKit's own localized
screen-reader strings landed in 2.26.0, the busy-state props these stubs use landed in 2.47.0, and
the release dialog's close on confirm landed in 2.49.0, and the quiet new-tab link and the editor
that reports only a person's edit landed in 2.56.0; 2.68.0 is the oldest release the package's
own suite installs):
<livewire:legal-consent.reconsent-form />
<livewire:legal-consent.consent-settings />
Each settings row carries outstanding — true when the subject holds an older major than the
one that is live, which is the state the gate will otherwise compel. It is computed exactly as
Consent::statusFor() computes it, and a voluntary consent is never outstanding (Art. 7(4)). The
shipped views render it as Action required; if you render your own, read that key.
Both screens expose object and terminate besides their own action. Every public method of an
embedded Livewire component is reachable whether or not the template renders a button for it, so
if your product has no answer to those two, switch them off at the embed rather than deleting
buttons:
<livewire:legal-consent.consent-settings :allow-objection="false" :allow-termination="false" />
<livewire:legal-consent.reconsent-form :allow-objection="false" :allow-termination="false" />
A transition that does not apply answers 404, not 500 — a key nothing published, a key
unpublished between the render and the click, or an objection against a consent (which is withdrawn,
not objected to). The reason rides on the exception and reaches neither the page nor your log
(Laravel never reports a NotFoundHttpException), so report 404s yourself if you want to see
them.
If you call the manager or the facade directly instead of through the component, catch these
yourself — all in Pushery\LegalConsent\Exceptions: LegalDocumentNotFound,
NotWithdrawableException, NotObjectableException, NotTerminableException,
NotConsentBearingException. They are refusals to record a row the ledger cannot take back, not
failures to work around.
Save a model before you record a consent for it. A subject without a key throws
SubjectWithoutKey, because a row that names nobody is found by no read and reached by no erasure.
Record after save() or in a created listener.
The last one catches the case that is easiest to reach by accident: an informational document —
an Impressum, a cookie policy — is published so it can be read and asks the reader for nothing, so
Consent::accept() and Consent::record() refuse it. The JSON API answers 422 with an error
of not_consent_bearing. Do not offer such a document as something to accept; render it, and let
the gate ignore it.
Consent::record() also refuses an action the document's type cannot carry, with
IncompatibleConsentActionException. Contract terms and privacy notices are Acknowledged (or
ReAccepted after a material change); Granted, Withdrawn, Declined, OptInRequested and
Confirmed belong to documents with requires_explicit_optin, and a Confirmed additionally needs
a preceding OptInRequested row for the same subject and key. The named transitions —
accept(), withdraw(), object(), terminate(), confirm() — ask their own half already, so
prefer them over record() unless you genuinely need the untyped write. The ledger is append-only,
so a row asserting a state the law has no shape for can never be corrected.
No registration form to put a checkbox on? Sign-in through an external provider raises
Registered with no form, so there is nothing to validate a tick against. Show an interstitial
after authentication and mount the bundled form as a first-use gate:
<livewire:legal-consent.reconsent-form
:method="\Pushery\LegalConsent\Enums\ConsentMethod::FirstUseGate" />
It lists every mandatory document the subject does not hold — Consent::firstAcceptance() is the
same read if you build your own screen — and records each with that method. "Does not hold" is
wider than "never accepted": somebody who withdrew, declined or terminated holds nothing either,
and is asked again rather than served without an agreement. Do not reach for
ReConsentGate there: the ledger is append-only, and that value asserts an acceptance after a
document changed, which never happened.
Consent::outstanding() answers a different question and will not do: it filters on the notice
mode of a version change, and a first acceptance is not a change.
Set legal-consent.gate.first_use to true to have the middleware stop people until they have
been through that screen — and only together with the screen, or they land on a consent route that
tells them nothing is due.
A middleware of your own asks the same question as the package's with
Consent::owed($user, firstAcceptance: true): both sets, each document once, from one read of the
ledger.
Asking about several documents at once costs one read of the ledger rather than one per key:
$held = $user->hasAcceptedCurrentLegalMany(['terms', 'privacy']);
// ['terms' => true, 'privacy' => false]
Alert on the scheduled sweeps. Bind Pushery\LegalConsent\Contracts\LegalConsentMonitor (the
default binding discards everything) and each sweep calls heartbeat(string $task, int $processed).
Three task names are the ordinary beat — legal-consent:prune, legal-consent:dispatch-notices,
legal-consent:close-objection-windows — and seven are sent only when a run failed:
legal-consent:dispatch-notices.deficient — a version went out without its mandatory notice
content. § 308 Nr. 5 lit. b makes the silence warning a validity condition, so silence cannot bind
against those notices at all: the wording has to be fixed and the notice re-sent.legal-consent:dispatch-notices.held — a notice still owed because the audience exceeded
notifications.max_recipients_per_run.legal-consent:dispatch-notices.canceled — a notice still owed because a NoticeDispatching
listener held it back. It goes out on the first run the listener lets through; --force does not
reach a listener.legal-consent:dispatch-notices.unreachable — subjects whose notice failed
notifications.max_attempts times. It is still owed: correct the address and run
legal-consent:renotify, or erase the subject.legal-consent:dispatch-notices.expired — a deemed-consent version still owed its notice after
the objection deadline had passed. Such a notice can found no acceptance, so nothing is sent: the
subjects it did not reach need a new version with a new objection deadline.legal-consent:close-objection-windows.unproved — subjects that could not be deemed for want of a
§ 308 Nr. 5 lit. b warning delivered by the objection deadline.legal-consent:close-objection-windows.late — subjects whose notice arrived only after the
objection deadline, which left them no period to object; they need a new version.Alert on those seven by name. The ordinary heartbeat is sent BEFORE the failure branch, so a run
that held a legally required notice back still beats as usual with the count it managed.
legal-consent:prune is the exception: it beats last, after it has re-linked the tamper chain it
broke, so a sweep that dies anywhere, the repair included, sends no beat.
Consent::fake() swaps the manager for an in-memory double — no database, no migrations of this
package's tables into your test schema.
$fake = Consent::fake();
$this->post('/register', [...]);
$fake->assertAccepted($user, 'terms');
Reads default to a fully-consented subject (nothing outstanding, hasCurrent() true, nothing
published), so a test about something else is never blocked by a gate it did not mention. Declare
what you care about with owes($user, 'terms'), publishes($document), checklistIs(...),
statusIs([...]) or historyIs([...]).
Writes are recorded, not performed: the returned LegalConsent carries the attributes but
exists stays false. Assertions: assertRecorded, assertNotRecorded, assertAccepted,
assertWithdrawn, assertNothingRecorded, assertRecordedCount, plus recorded() for anything
else. assertAccepted matches granted, acknowledged and re_accepted — never
deemed_accepted, because silence is not an act of the subject.
A minimal, complete adoption: publish the config, author resources/legal/terms/de.md with
version: 1.0.0, run legal-consent:publish terms de --active, put legal.consent on the
authenticated route group, render Consent::published('terms', 'de')?->html on the public page, and
embed <livewire:legal-consent.reconsent-form /> on the consent route. Make sure schedule:run
is wired up — do NOT add a schedule entry for legal-consent:dispatch-notices, the package
registers it itself, hourly, behind withoutOverlapping() and onOneServer(). A second entry runs
outside that mutex, and because the watermark is only stamped after a send, the result is a
duplicate legal-change notification per server. Put legal-consent:publish --all --editorial in
the deploy script so a fresh database is never left with empty legal pages.
The public page renders the stored bytes unescaped, which is the one place in this integration where that is the right call:
{{-- $document came from Consent::published('terms', app()->getLocale()) in the route --}}
@if ($document !== null)
<h1>{{ $document->title }}</h1>
{!! $document->html !!}
@endif
html is exactly what the package sanitized before it froze the row, and the hash the ledger
carries is the hash of those bytes. So {{ }} there is not the safe choice, it is the wrong one —
the page shows visible tags — and sanitizing the value a second time changes the bytes, which
separates the page from the proof. Everything else on a consent screen is escaped as usual.
LegalHtmlSanitizer::allowedElements() and allowedAttributes(), which grow with the package.registrationChecklist(), or the
form will block on a document that does not exist, or quietly omit one that does.legal_documents rows. They are append-only and the database refuses it; publish a
new version instead.legal-consent-users-cache or legal-consent-backfill casually. One drops a
column from your users table, the other backfills history — both are deliberate, separate steps.middleware.allowlist_paths. The gate resolves
it from the installation already. Livewire 4 derives the prefix from APP_KEY, so writing your
own /livewire-<hash>/* is green in development and silently wrong in production.document_url unset if your app has legal pages. Every consent surface then
shows a title the subject cannot open — including the re-consent gate, where they cannot continue
until they agree.document_url or gate.subject_filter. Both accept one, and both
break php artisan config:cache — a failure that first appears in the deploy, because nothing
local caches. Use an invokable class-string; legal-consent:doctor names the offenders.How can I help you explore Laravel packages today?