Skip to main content

User interface

The core is headless — it renders and proves, and never forces a UI framework on you. Three levels, pick one.

1. Plain Blade stubs

The default, with no dependency: the consent checkboxes, the grace-period banner, and a settings page. Publish and restyle them freely:

php artisan vendor:publish --tag=legal-consent-views

Give the withdraw button somewhere to go

The settings stub has a withdraw button, and a plain HTML form cannot call a Livewire action — that is the whole point of this level. Switch on the bundled session route and the package fills each row's withdraw_url for you:

// config/legal-consent.php
'routes' => [
'web' => true, // POST {web_prefix}/consent/withdraw
'web_prefix' => 'legal',
'web_middleware' => ['web', 'auth'],
'web_throttle' => '60,1', // shared with the Livewire actions; null to switch off
],

It withdraws and redirects back to the page the form was on, flashing legal-consent.status (or legal-consent.error where the document cannot be withdrawn at all — a contract is ended by ending the relationship, not withdrawn). The stub renders both.

Why not the JSON API

The API (level 3 below) sits behind the api middleware group: no session, no CSRF, and a 204 that would leave a form submitter on a blank page. This route sits behind web + auth and redirects. It is also always allowlisted by the enforcement middleware, like logout — a subject held at the re-consent gate can still withdraw a voluntary consent, because making that conditional on accepting something new is the coupling Art. 7(4) prohibits.

Leave it off and the stub renders no withdraw form at all rather than one that goes nowhere. Prefer your own route? Publish the stub and set withdraw_url yourself — the key is yours to fill.

The same rule now applies to the banner: without consentUrl it renders no call to action, where before 0.19.0 it rendered a link to #. A control that looks actionable and does nothing is worse than an absent one — a keyboard user reaches it, a screen reader announces it, and it takes them nowhere.

A retired document keeps its row

Retiring a document (is_active = false) does not end the consents already recorded against it. Such a holding stays on the "your consents" screen and in statusFor(), flagged retired, shown at the version the subject accepted and with its withdrawal control intact — but never as outstanding, because nothing is being enforced any more and that flag is what a screen turns into an invitation to accept. The row exists to let the subject END a holding, not to start one.

"Retired" means no active version of that key exists anywhere, not "none in this locale". A document published only in de is not retired on the en screen; it is simply not published there, which is what hasCurrent() and the gate already say.

Validation errors reach the plain stub too

Before 0.19.0 the plain consent-checkboxes stub rendered no validation error at all, so a failed mandatory consent was silent — the twenty-one error strings the package ships were unreachable there, and a screen-reader user got nothing. It now renders the message, marks the field aria-invalid, and merges the reference into the field's existing aria-describedby rather than adding a second one: two of that attribute on the same element means the browser keeps the first and discards the second.

The WireKit twin needed no change and takes no error prop. <x-wirekit::checkbox> resolves the message from the Laravel error bag by name and merges its own target into aria-describedby itself — passing the error in would set exactly the value the component already computes.

Binding the boxes to a Livewire property

Both checkbox variants restore their state from old(), which is right for a POST form and does nothing for a Livewire screen: old() is empty across a commit, so the component never learns the box was ticked. Pass bind and the control binds instead:

@include('legal-consent::consent-checkboxes', [
'documents' => $documents,
'bind' => 'accept',
])

Each control then carries wire:model="accept.{field}" and no checked attribute at all. Your component holds one entry per field:

/** @var array<string, bool> */
public array $accept = [];

Three things worth knowing before you use it.

The key is the field, not the document key. field is what the id, the name, the error bag and RegistrationRules already use, and the one control that is not a document — the Art. 8 age attestation — is named by its bare key rather than legal_{key}. Keying the array by document key would hand that control a property no rule validates.

The never-pre-checked rule moves to you. Without bind the stub can guarantee it: nothing but the visitor's own previous submit can tick a box. Under a binding the stub contributes nothing, so the bound value is the only thing that decides — initialize it to false. Nothing in the package can enforce that for you, and a pre-ticked box is not consent (CJEU C-673/17).

The accept-time hash goes inert. Livewire submits no form, so the hidden fingerprint input is never sent. A bound screen carries contentHash in its own state and hands it to the recorder itself; leaving it out keeps the prior no-guard path.

If you want none of this, omit bind and nothing about the stubs changes.

2. Livewire components

Opt-in, reactive, drop-in versions of the interactive screens:

<livewire:legal-consent.reconsent-form />
<livewire:legal-consent.consent-settings />

One-click accept, withdraw (Art. 7(3)), object, and terminate. They register automatically only if you have livewire/livewire installed, so the package stays dependency-free otherwise.

Every action is reachable, button or not

Livewire dispatches to any public method of an embedded component, so removing a button from a published view switches nothing off. Both screens expose object and terminate besides their own action, and if your product has no answer to them, say so at the embed:

<livewire:legal-consent.consent-settings :allow-objection="false" :allow-termination="false" />
<livewire:legal-consent.reconsent-form :allow-objection="false" :allow-termination="false" />

A disabled action returns 404 — it is one this instance does not have, and "you may not" would confirm it exists. All flags are locked server-side, so the browser cannot send them back.

The same form, as a first-use interstitial

Mounted with ConsentMethod::FirstUseGate the re-consent form asks a different question: not what changed, but what does this subject not hold at all — which covers somebody who never accepted, and somebody whose acceptance a withdrawal or termination ended.

<livewire:legal-consent.reconsent-form
:method="\Pushery\LegalConsent\Enums\ConsentMethod::FirstUseGate" />

That is the screen for an application whose sign-up has no consent step — an OAuth-only sign-in, an invitation link, an imported user base. It lists every mandatory document the subject has never accepted, contract terms and privacy notices alike, and records each with that method, because the ledger is append-only and a first acceptance filed as a re-consent would assert forever that a document changed when none did.

Before 0.20.0 this mount rendered "everything current, nothing to do", however much was owed. See Way D.

Pair it with legal-consent.gate.first_use if you want the middleware to stop people until they have been through it. Do not turn that on without putting this screen on your consent route.

One route, both questions

The two mounts above each answer half of what the gate holds people for. With legal-consent.gate.first_use on, EnsureLegalConsent blocks on the union of "what changed" and "what was never accepted" — so a single consent route mounted as a re-consent gate sends every first-use subject to a form with nothing on it, and the gate keeps holding the next request. That is a dead end, not a loop.

Mount it to answer the gate's own question instead:

<livewire:legal-consent.reconsent-form :answers-gate-question="true" />

It renders both sets on one screen and records each document with the provenance its own question impliesfirst_use_gate for one the subject never accepted, re_consent_gate for one that changed since they did, within the same submit. That per-document part is the reason this is not simply a concatenation: the method lands in an append-only ledger row, and a first acceptance filed as a re-consent asserts a change that never happened.

Use it when your application has one consent route, which is what routes.consent_name describes. Keep an explicit :method when you mount two separate routes and want each to stay one question.

A document that is in both sets — never accepted and materially changed since it was published — is recorded as a re-consent, because that is the stricter of the two statements.

Which rows are asking for something

The settings screen tells two positions apart that look identical otherwise:

The subject…The row says
never accepted this documenttitle and version, nothing more
holds an older major than the one that is livetitle, version, and Action required

Only the second one ends at the gate. The screen where it could have been done voluntarily is the one that has to say so — otherwise the package is visible when it compels and silent when it invites.

Each row carries the flag as outstanding, computed exactly as Consent::statusFor() computes it, so the screen and the status map cannot disagree. A voluntary consent is never outstanding, however long it goes ungiven: demanding one would be Art. 7(4).

If you render your own screen from ConsentPresenter::settingsFor(), the key is simply there — adding it breaks nothing that does not read it.

Giving a consent back, not only taking it away

ConsentSettings can also give a voluntary consent that is not currently held — but only when you ask for it:

<livewire:legal-consent.consent-settings :allow-grant="true" />

It is off by default, and that is the opposite of the two flags above. Granting is the only direction on that screen which writes an assertion that the subject agreed; withdrawal, objection and termination all remove or contest one. Since every public method is reachable button or not, switching it on by default would have added an endpoint that creates a proof row saying "they agreed" to every existing installation. That is asked for, not assumed.

Turn it on when your product has consents worth re-subscribing to — and especially when there is no registration form at all, because then it is the only way a voluntary consent can ever be given.

It refuses anything that is not a voluntary consent. A contract or an acknowledgement is accepted where its full text is presented — a registration form, the re-consent gate, a first-use interstitial — because the acceptance has to be informed (Art. 7(1)), and a toggle beside a title is not a presentation of a contract. Such a call answers 404, like every other refused transition.

Both shipped views render the control opposite the withdraw button, mutually exclusive with it: a row offers exactly one of the two, never both.

Why the grant button has no confirmation dialog

The WireKit view puts a withdrawal behind an alert-dialog and a grant behind nothing. Giving is reversible in one click on that very screen; withdrawing appends an irreversible row. Confirming the harmless direction and not the permanent one is the wrong way round.

This is separate from the type rules in Objections and terminations, which refuse a transition the document's class cannot carry no matter who calls it. These flags decide whether the endpoint exists at all.

Those type rules answer with 404 too, and so does a document_key that is not published — a typo, or a document unpublished between the render and the click.

The reason is on the exception, and nowhere else. The subject gets the framework's plain 404 page; the sentence naming the document and its legal class stays out of it, which is the point. But it does not reach your log either — NotFoundHttpException is one Laravel never reports — so if you want to see these, report 404s yourself (a custom exception handler, or an APM configured to capture them). Do not expect one to show up while debugging.

The JSON API distinguishes the two cases in its body (404 unknown_document versus 422 not_terminable) because a machine client can act on the difference — see Recording consent.

3. WireKit-native variant

If your app has WireKit, you already have these — there is nothing to publish. legal-consent.ui.variant defaults to auto, which serves the WireKit view set whenever WireKit is installed at ≥ 2.26.0, and the plain set otherwise:

// config/legal-consent.php
'ui' => [
'variant' => 'auto', // 'auto' | 'plain' | 'wirekit'
],

Pin plain to keep the bare stubs on a WireKit app, or wirekit to serve the themed set regardless of what is installed. A view you published into resources/views/vendor/legal-consent still wins over both — your copy is checked first, exactly as before.

Why the version floor is part of the automatic choice

A Blade component tag compiles unconditionally. Switching an app onto views that name a component its WireKit does not have would replace a silent styling problem with a hard exception — and the re-consent gate is the screen a subject cannot get past, reached exactly when a legal change lands. So auto only ever serves what this package has proven against; below the floor it stays plain and legal-consent:doctor says so rather than leaving you to notice by eye.

Publishing is still how you customize them:

php artisan vendor:publish --tag=legal-consent-wirekit

Built from real <x-wirekit::*> components — callouts, checkboxes, badges, and a live countdown on every deadline — so they inherit your theme instead of shipping their own CSS. The tag covers the Livewire views too, so the reactive screens are themed as well, not just the static stubs.

Needs pushery/wirekit ≥ 2.26.0 and @wirekitScripts in the layout for the live countdown and the withdraw alert-dialog. The floor is not cosmetic — below it three things degrade, and the first two do so without saying anything at all:

  • below 2.17.1 the admin editor's wire:model lands on the wrapper instead of the textarea, so everything typed into it is lost on save;
  • below 2.17.1 the manager table cannot emit a row header, leaving every status cell without a programmatic association to its document (WCAG 1.3.1);
  • below 2.26.0 WireKit carries no translation catalog of its own, so the screen-reader strings it renders inside these views — the external-link hint, an alert's Hinweis: prefix, the dismiss label — are announced in English on a German consent surface. That is the one a sighted reviewer cannot see at all.

The package's own test suite renders these views against the installed WireKit and fails on any component the release lacks, so the published stubs never reach for one your app cannot resolve. Two of those tests fail outright below 2.17.1 — verified by running the suite against 2.13.0 rather than inferring it from a changelog — and one asserts the German announcement in the rendered markup, which is what holds the 2.26.0 half of the floor honest.

Screen-reader strings in your locale

WireKit runs its own screen-reader strings (the external-link hint, an alert's Notice prefix, Dismiss) through __() with the English text as the key. Since 2.26.0 it ships catalogs for en and de and registers them itself, so a German app gets German announcements with no publishing step and no configuration.

That covers two of this package's seven bundled locales at the floor this package declares. Newer releases carry more: measured against WireKit v2.35.0, es, fr, it, nl and pt all resolve the strings in their own language, so on a current install there is nothing to supply.

⚠️ This paragraph used to say those five are "still announced in English", in the present tense, and that stopped being true without anyone noticing. The sentence was correct when it was written and describes the floor, not your install — which is the distinction to keep, because a version range is what this package guarantees and a single version is what you happen to have.

Check yours rather than trusting either sentence. The key is the English text, because the kit calls __() with it:

php -r 'echo json_decode(file_get_contents("vendor/pushery/wirekit/lang/es.json"), true)["(opens in new tab)"] ?? "not translated", PHP_EOL;'

If a locale you serve comes back untranslated, supply it in your app's lang/{locale}.json, which is the file Laravel's JSON loader reads and which takes precedence over the kit's own:

{
"(opens in new tab)": "(se abre en una pestaña nueva)",
"Notice": "Aviso",
"Dismiss": "Descartar"
}

Do not translate the copy that --tag=wirekit-lang writes into lang/vendor/wirekit/: the JSON loader does not read that directory, so edits there have no effect.

This package deliberately does not ship those five for you. JSON string keys are app-global, so defining them here would silently retranslate every other WireKit component in your app — a package has no business making that choice for an application it cannot see.

A withdrawal is irreversible — it appends a Withdrawn row to an append-only ledger — so it asks first, via a themed alert-dialog rather than the browser's native confirm window.

The rules every variant bakes in

These are non-negotiable, in all three levels:

  • Checkboxes are never pre-checked (Planet49, C-673/17).
  • A real consent is never required (Kopplungsverbot, Art. 7(4)).
  • The full text is always linked and retrievable (clickwrap, § 305 II BGB).

Bundled translations

Every string the package renders — the grace-period banner, the consent and settings stubs, the re-consent notification, the validation messages, and the acceptance wording — ships translated in every bundled locale out of the box: German, English, Spanish, French, Italian, Dutch, and Portuguese.

Override any of them by publishing the language files:

php artisan vendor:publish --tag=legal-consent-lang

This is independent of legal-consent.locales, which is the set of locales you publish your own legal documents in — leave it at [de, en] even while the UI is available in every bundled locale.