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.

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.

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.