Weave Code
Weaver
Helps Laravel developers discover, compare, and choose open-source packages. See popularity, security, maintainers, and scores at a glance to make better decisions.
Feedback
Share your thoughts, report bugs, or suggest improvements.
Subject
Message
legal-consent-for-laravel
pushery/legal-consent-for-laravel
claude-code
laravel
terms
privacy
legal
audit-trail
consent
gdpr
dsgvo

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.

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

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

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

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.

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:

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

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.

$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:

{{-- $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.
Weaver

How can I help you explore Laravel packages today?

Conversation history is not saved when not logged in.
Prompt
Add packages to context
No packages found.
ignibyte/aic-framework
glavweb/data-schema-bundle
glavweb/datagrid-bundle
tacticmedia/scim-server
uni-method/frame
neika-ai/sdk
adachsoft/php-code-style
pharaonic/laravel-notification-dependencies
beautinow/translug
dev-brainiac/bot-cloaked
octoberrain/html
octoberrain/extension
octoberrain/exception
octoberrain/contracts
adachsoft/api-version-bundle
cgv/localized-resource
noith/settings
kda/laravel-package
kda/filament-dynamic-navigation
bypersoft/signbox