Skip to main content

Managing legal texts

Two Livewire screens ship as publishable stubs — a manager (releases every locale of a document atomically) and a per-locale editor.

They are opt-in and fail-closed

Name a Gate ability in admin.ability and define it. With the ability unset, or the Gate denied, both screens return 404 and never reveal they exist — there is no ungated publish path.

// config/legal-consent.php
'admin' => ['ability' => 'manage-legal-texts'],

// a service provider
Gate::define('manage-legal-texts', fn ($user) => $user->isLegalAdmin());

Mounting them

Mount them as Livewire components inside your own admin routing:

<livewire:legal-consent.legal-text-manager />
<livewire:legal-consent.legal-text-editor :document-key="'terms'" :locale="'de'" />

The editor mounts with a document key and a locale (it edits one draft at a time); the manager takes no parameters.

The WireKit variants are served automatically when WireKit ≥ 2.26.0 is installed. Publish the plain stubs with --tag=legal-consent-views to customize them, or the WireKit ones with --tag=legal-consent-wirekit (see the WireKit-native variant). The editor sanitizes on store, so its preview is the exact bytes a publish freezes.

The WireKit editor's toolbar is deliberately narrow: bold, italic, strikethrough, link, and the two list types. It offers only formatting the sanitizer keeps, so nothing an admin applies can vanish between typing it and storing it. The plain stub is a textarea — you write the markup yourself, and the same sanitizer decides what survives.

The rich editor must KNOW every tag your texts contain

That guarantee runs one way. The sanitizer keeps more than the toolbar can create — tables (table, thead, tbody, tr, th, td), headings, blockquote, pre/code, sub, sup, abbr, mark — because a legal text does not only come from that toolbar. It also comes from a seeder, an import, or legal-consent:publish, and it renders exactly as written.

The rich editor's engine is your application's dependency, not this package's (WireKit reads window.wirekitEditor, and this package ships no editor configuration at all). A Tiptap factory built on StarterKit alone does not know a table node — so it drops it while parsing, before you have touched anything. The document looks emptied the moment the screen opens, and the next save stores that loss. Nothing errors, because nothing went wrong from the editor's point of view.

So: if your texts use a tag beyond the toolbar's own, register the matching extension in your editor factory (@tiptap/extension-table and its row/cell/header parts, for the table case), or keep those documents out of the rich editor and edit them through the plain stub, which passes your markup to the sanitizer untouched.

Binding Pushery\LegalConsent\Contracts\LegalTextTranslator enables the editor's "Translate" action; a machine translation is always produced unreviewed and must be reviewed by a human before it can be released.

Take the translation off the request

'translation' => [
'queue' => true, // default: false

'connection' => 'redis-legal', // default: null — your default connection
'queue_name' => 'legal-translations', // default: null — that connection's default queue
],

The editor calls your translator inline by default, which is right when it answers quickly and wrong when it does not: a privacy notice is around 15 kB of text — an ordinary length for one and a long time for a language model — and a consumer measured that inline call ending in a 500 twice in one day.

With translation.queue on, the action returns immediately, a queued job does the work, and the editor polls until the draft appears. It needs a queue worker, which is why it is off by default: off is the behavior that needs no infrastructure.

The duration is not this package's to guess. LegalTextTranslator is your binding and might answer in microseconds or in minutes, so the answer is not a longer timeout — any limit chosen here would be wrong in both directions — but a request that does not wait.

Give it a lane of its own. With both keys unset the job goes where your default jobs go, and that is the wrong place for a call measured in minutes: a worker configured for short jobs is killed at its timeout long before a language model answers, and raising that timeout would apply to every job your application runs. Point these two keys at a connection whose retry_after and worker timeout suit the call, and the rest of your queue is unaffected. An empty string counts as unset, so an env-backed key that exists and is blank still means the default.

Nothing else changes: the job produces the same unreviewed machine draft the inline path produces, and the human review gate is untouched.

Every finished run ends in a sentence on the editor. A translation that arrives is announced and replaces the text on screen. One that does not — a translator that could not answer, a document the parser refused, a worker killed at its timeout or replaced mid-job — says that it ended, and carries the reason wherever there is one a person can act on. It never claims a translation that did not happen: a run that left the draft exactly as it was announces nothing of the sort. The draft is untouched in every one of those cases, so pressing Translate again is safe and answers the same way.

What the manager's release publishes

The manager releases a document in the mode a material change of its type takes: an active re-consent for a contract or a real consent, info-only for a privacy notice, which is acknowledged and never gated, and a silent activation for an informational page. Until 0.32.0 it used an active re-consent for every document, which the publisher refuses for a privacy notice, so no privacy notice could be released from the manager.

An active re-consent has to raise the major version, because the gate compares major versions. A contract or a consent released from the manager as a minor or a patch comes back as a status message naming the version to publish instead, and nothing is written. See The four notice modes.

Releasing a change that binds by silence

The editor can release a change as deemed consent — the mode where silence binds if the objection window closes without an objection (§ 308 Nr. 5 lit. b BGB). Until 0.23.0 only the CLI could drive it, so an application with an admin UI had no in-app path to a capability the package implements end to end.

It sits in the editor rather than on the manager's overview grid, and that is deliberate: a release that binds people by their silence is a legal call about one specific change, and the editor is the screen where somebody has actually read the text.

Five fields drive it — three dates and two flags:

FieldWhat it sets
announce datewhen the notice goes out
objection deadlinethe date an objection has to arrive by
effective datewhen the change takes effect
offers terminationthe change grants a free right to terminate (§ 675g Abs. 2 BGB, P2B Art. 3)
keeps unmodified on offerwhoever objects stays on the version they already hold

A window that runs backwards, falls short of the statutory lead time, or carries a date the screen cannot read comes back as a status message, not an exception — and the message carries the package's own numbers: which minimum applied, and which dates it read. A date is read strictly: 2026-02-31 is refused by name rather than rolled forward to March 3rd, because the value ends up in an append-only proof row that a later correction cannot reach. That is the point of driving this from the editor rather than only from the CLI. A release the package refuses is a release you can correct on the spot.

The same screen refuses to release at all while a locale is still blocked — an unreviewed draft, a missing text — and names each locale with its reason.

After the release, legal-consent:close-objection-windows is what actually closes each window and turns silence into an acceptance. It is scheduled by the package; you do not add it yourself.

⚠️ Two heartbeats decide whether that silence binds, and they only fire when it does not. legal-consent:close-objection-windows.unproved reports subjects that could not be deemed because no delivered § 308 Nr. 5 lit. b warning is on record for them, and legal-consent:dispatch-notices.deficient reports versions whose notice went out without its mandatory content — silence cannot bind against those at all. Alert on both by name; see Retention and sweeps.

Published versions are frozen

A published legal_documents row is immutable proof — the exact sanitized text a subject was shown and the hash the ledger snapshots, one text because they are one row.

A database trigger (PostgreSQL, MySQL, SQLite) and a model hook reject any edit to a proof column after publish; a direct write raises LegalDocumentFrozenException (through the model) or a database error (raw SQL).

Correcting a text is a new version, never an in-place edit.

Pages nobody agrees to

An Impressum, a cookie policy or an accessibility statement is a page you must publish and keep current — and it asks the reader for nothing. Such a page is registered with the informational basis, and the shipped registry already carries imprint as the worked example:

'imprint' => [
'source' => 'markdown',
'legal_basis' => 'informational',
],

Add a cookies or accessibility entry the same way; all imprint needs is its Markdown file.

It uses everything on this page — the editor, the review gate, the translation seam, the sanitizing pipeline, the frozen published row — and none of the consent machinery. It never appears in the registration checklist, never writes a ledger row, never gates access, and never sends a notice.

Publish it silently:

php artisan legal-consent:publish imprint de --editorial

The other modes are refused for this class. Each of them describes an audience that does not exist here: there is no acceptance to deem or to re-request, and the notice sweeps resolve their recipients from ledger rows — which this class never writes.

Locale fallback

This is the one class that falls back. If en is not published, an informational page serves your default_locale instead of nothing. Every other class keeps the strict behavior on purpose — see below.

Its ui_wording is null, and that is the point rather than an omission. Every other class freezes the exact acceptance sentence a subject clicked into that column, copies it verbatim into each ledger row, and folds it into the hash chain — so whatever lands there is permanent. A page that binds nobody has no such sentence, and there is no honest value to invent for one. If your own view renders the wording, guard it:

@if ($document->uiWording !== null)
<p>{{ $document->uiWording }}</p>
@endif

A wording supplied by the source for an informational page is dropped rather than stored, for the same reason: honoring it would freeze a misunderstanding into a column nothing can correct.

Where the heading comes from depends on the source, and the two are not interchangeable. A markdown page — the example above — takes it from the file's frontmatter title:. A drafts page takes it from lang/*/titles.php, looked up by the document key: imprint, cookies and accessibility ship translated in all seven locales, and an unknown key renders under its raw key. Editing titles.php has no effect on a markdown-sourced page.

Render a public page from the frozen row — never the source, which can drift between an author's edit and the next publish:

use Pushery\LegalConsent\Facades\Consent;

$document = Consent::published('terms', app()->getLocale());

// $document?->html is the exact stored bytes and $document?->contentHash the exact stored hash —
// the same text the ledger proves. Returns null before that locale is published (render an "in
// preparation" shell), and never falls back to another locale.

Render those bytes unescaped:

@if ($document !== null)
<h1>{{ $document->title }}</h1>
{!! $document->html !!}
@endif

{!! !!} is correct here and {{ }} is not, which is worth stating because the habit runs the other way. The value is HTML the package produced itself: the Markdown source went through the render pipeline and the package's one sanitizer before the row was frozen, and content_hash is the hash of exactly these bytes. Escaping them shows the reader visible tags; putting them through a second sanitizer changes them, and the page then no longer matches the hash the ledger carries. Everything else about the document — its title, its acceptance wording — is escaped as usual.

legal-consent:verify-documents re-checks every published row against its stored hash and flags notice-mode divergence across a version's locales — see the command reference.