Skip to main content

Managing legal texts

Two Livewire screens ship as publishable stubs — a manager (releases a document as one version in every language that is ready) 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.

The ability is checked when the component boots, which happens on every request it serves. That includes the Livewire update requests a click sends, and those go to Livewire's own endpoint rather than through the middleware of the route you mounted the screen on. So you need no middleware of your own for this, and revoking the ability takes effect on the next click rather than on the next page load.

// 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.68.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.

To prove your editor keeps them, test it against the sanitizer's own list rather than a copy of it. LegalHtmlSanitizer::allowedElements() returns every element a sanitized text keeps, and LegalHtmlSanitizer::allowedAttributes() the attributes each of them may carry, so the test follows the package when the list grows.

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.

The queued translation runs as the admin who asked for it. A worker has nobody signed in, so a translator that bills or limits per user would read nobody: the job carries the identifier of the admin who pressed the button and the guard it read them from, signs that user in for your translator's call alone, and puts the worker back the way it found it afterwards. Your translator therefore reads the acting user the same way it does on a request, with no extra parameter. An admin who is gone by the time the worker runs translates unattributed rather than failing.

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, worded in the language of the editor that reads it. 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.

Every editor open on the draft takes the result, not only the one that pressed Translate. A second admin's editor, or the same one after a reload, waits on the running translation as well and takes its text when it lands. While it runs, the Translate button is disabled and says so, and a press that reaches the server anyway is answered with a sentence rather than a second job: a translator that bills per call is not paid twice for the same text.

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.

A release covers every language whose draft is reviewed and still matches its source, and always the source language. A language left out, because its translation is not written, not reviewed or was discarded, keeps the version it has live, and its readers stay bound to the text they accepted in their language until its own translation is released. The manager names those languages beside the release button, so leaving one out is a decision you see before you make it. The WireKit manager names them again in the dialog that confirms the release; the plain one has no such dialog and releases on the click. This holds for a contract and a consent as well: nobody is bound in a language they did not read, the gate reads the version of the reader's language, and a change notice reaches the people who agreed in a language that changed. Two versions of one document can therefore be in force at once, one per language, and every ledger row names the version and the language it was given in. When the source itself is not ready, nothing is released, and the overview names the source and why.

Neither screen asks for a version number. A release derives it with LegalDocumentReleaser::nextVersionFor(): the next major for a mode that gates, the next minor for one that does not, and 1.0.0 for a text published for the first time. A version you set yourself with LegalDraftWriter::setVersion() is used as long as no release has used it yet. One that a release already used for another text is replaced, so a changed text can be released again, and releasing an unchanged text again publishes the version it already has.

The count under a release​

Once a release is written, both screens say how many people it reaches: everybody who agreed to an earlier major version in one of the released languages, or, for a mode that does not gate, everybody who agreed at all. Each person counts once, whichever of those languages they agreed in.

The count reads every consent of the document in those languages, so it grows with the ledger, and it runs inside the request that made the release. admin.release_count_seconds is the time the database gets for it, 5 seconds unless you set it. On PostgreSQL and MySQL a count that takes longer is stopped: the release stands, and the status line names it without the number. 0 skips the count, and null waits for it however long it takes. SQLite has no statement timeout and counts to the end under any value above 0.

A release screen of your own gets the same answer from LegalDocumentReleaser::affectsReleaseWithin($released, $seconds), which returns null when the count did not finish in time. affectsRelease($released) counts without a limit.

Moving a document from Markdown to the drafts store​

The acceptance sentence travels with a version: it is frozen into the published row and copied into every consent given under it. A Markdown source carries its sentence in the front matter (ui_wording:), and the drafts store does not read that file. It takes the sentence from the translation files, legal-consent::wording.{key}, so a document you move keeps its text and changes its sentence to the package's own unless you set yours there:

// lang/vendor/legal-consent/en/wording.php
return [
'terms' => 'I accept the Terms of Service.',
];

Do it for every language before the first release from the drafts store. legal-consent:publish and its --dry-run name the old and the new sentence whenever a version changes it, so a sentence that moved by accident shows up before or after the publish rather than in the next dispute.

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. The editor offers it only for a contract's terms: a privacy notice is acknowledged, a consent is never deemed, and the publisher refuses the mode for both.

Seven fields drive it: three dates, the regime, the change class 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
regimethe legal regime the change is made under, which decides the statutory minimum of the window
change classyour own classification tag, the same one the command line takes
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

The three dates and the regime are required. A change that binds by silence rests on its window, and without a regime no statutory floor applies to it, such as the two months § 675g BGB demands for a payment contract. Those months count backward from the day the change takes effect, and that day is not counted: for a change in force on 1 September, the notice has to reach the reader by 30 June. The screen names every empty field before anything is released.

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 while the source language is not ready — an unreviewed draft, a missing text — and names it with its reason. A translation that is not ready stays out of the release and keeps the version it has live. A refusal of the version itself, such as a version below the active one or a major that has to gate, is worded in the screen's language too, on this screen and on the overview.

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.

Four 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 § 308 Nr. 5 lit. b warning is on record as delivered to them by the objection deadline, and legal-consent:close-objection-windows.late those it reached too late to leave them the objection period, after the deadline or too close to it. legal-consent:dispatch-notices.expired reports versions whose deadline passed before their notice went out, so nothing was sent, and legal-consent:dispatch-notices.deficient versions whose notice went out without its mandatory content — silence cannot bind against those at all. Each of these is repaired by a new version with a new objection deadline, not by sending the notice again. Alert on all four 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.

A version a consent points at cannot be deleted either: its row holds the text that consent was given to, and the ledger keeps only its hash. A trigger refuses the DELETE on all three engines, and on PostgreSQL a TRUNCATE of legal_documents as well while any consent points at a version in it. MySQL runs no trigger on TRUNCATE TABLE, so there the statement is held back only by the privileges of your database user: it needs DROP. A version nobody consented to stays deletable. Retire a version with is_active = false instead.

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.

A release of such a page covers every language whose draft is reviewed and still matches its source, and always the source language. A translation that is written but not reviewed yet does not hold the page back: that language keeps the version it has live, or serves your default_locale where it has none. When the source itself is not ready, nothing is released, and the overview names the source and why. An acknowledgment with locale_fallback is released the same way.

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. It also reports a version that was released while it was still a placeholder: one on 0.0.0, or one whose text carries LEGAL-PLACEHOLDER or another marker you list in placeholder_markers. A document on the draft store that has never been released is reported too, with its key and locale, because its page shows the file it ships with until the first release. Run it with --placeholders-fail before a launch, and a placeholder or such a draft fails the run instead of warning.