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:
| Field | What it sets |
|---|---|
| announce date | when the notice goes out |
| objection deadline | the date an objection has to arrive by |
| effective date | when the change takes effect |
| offers termination | the change grants a free right to terminate (§ 675g Abs. 2 BGB, P2B Art. 3) |
| keeps unmodified on offer | whoever 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.
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.
Rendering the public legal 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.