pushery / legal-consent-for-laravel
Court-proof, versioned legal consent for Laravel — GDPR-correct separation of contract acceptance, notice acknowledgement, and consent, with an append-only audit trail.
Package info
github.com/pushery/legal-consent-for-laravel
pkg:composer/pushery/legal-consent-for-laravel
Requires
- php: ^8.4
- ext-dom: *
- ext-json: *
- ext-libxml: *
- illuminate/console: ^13.0
- illuminate/contracts: ^13.0
- illuminate/database: ^13.0
- illuminate/notifications: ^13.0
- illuminate/support: ^13.0
- league/commonmark: ^2.7
Requires (Dev)
- larastan/larastan: ^3.0
- laravel/pint: ^1.0
- livewire/livewire: ^4.3
- orchestra/testbench: ^11.0
- pestphp/pest: ^4.0
- pestphp/pest-plugin-browser: ^4.3
- pestphp/pest-plugin-laravel: ^4.0
- pestphp/pest-plugin-type-coverage: ^4.0
- rector/rector: ^2.0
- spaze/phpstan-disallowed-calls: ^4.12
Suggests
- laravel/fortify: Record registration consent inside the account-creating request (Way A).
- livewire/livewire: Opt-in reactive UI: the ReConsentPage and ConsentSettings full-page components (publish the plain or WireKit-flavored views).
README
Legal Consent for Laravel
Court-proof, versioned legal consent for Laravel. It is a document-acceptance ledger: the package renders and proves your legal texts — it does not own them.
Why this exists
A registration does three legally distinct things, and treating them as one (a single "I accept everything" checkbox) is a common — and real — GDPR violation:
| Document | Legal basis | UI | Blocking? | Withdrawable? |
|---|---|---|---|---|
| Terms / contract | Art. 6(1)(b) — contract | "I accept …" checkbox | yes | no (you cancel, not withdraw) |
| Privacy notice | Art. 13/14 — information | "I have read …" — never "I consent" | takes notice | n/a |
| Marketing / analytics | Art. 6(1)(a) — consent | separate, granular opt-in | no (Art. 7(4)) | yes, any time (Art. 7(3)) |
This package keeps them separate by design, and proves acceptance the way the law requires (Art. 7(1); EDPB 05/2020 §108): it stores the exact text a user was shown, its version and hash, and the server-side context — not just a timestamp.
Highlights:
- Append-only audit ledger — every acceptance/acknowledgement/withdrawal is one immutable row with denormalized proof (hardened by a DB trigger on Postgres/MySQL and an app-layer guard everywhere).
- Versioned documents — a SHA-256 hash detects a change; you classify how it must be communicated (its notice mode); only a core contract change forces active re-consent.
- Four notice modes — an editorial change is silent; an info-only change (a § 675g payment-contract update, a material privacy notice) is announced but never blocks; a minor contract change can bind by silence with an objection window (Zustimmungsfiktion, § 308 Nr. 5 BGB); only a material core change gates — each with durable-medium delivery proof.
- Interchangeable content sources — Markdown files (default), the database, or any foreign CMS via a small resolver.
- Fortify-optional — record consent three ways (a trait, an event listener, or a
headless JSON API).
laravel/fortifyis never required. - Optional, off by default — a tamper-evidence hash chain (
legal-consent:verify-ledger), an Art. 8 age gate, and multi-tenancy scoping — each a single config switch. - Optional reactive UI — plain Blade stubs by default; opt-in Livewire components and a WireKit-flavored variant when you want them (no hard Livewire/Flux dependency).
Compatibility
| Supported | |
|---|---|
| PHP | 8.4, 8.5 |
| Laravel | 13.x |
| Databases | SQLite · PostgreSQL · MySQL 8.4 LTS |
Every database-touching path is tested against real PostgreSQL and real MySQL 8.4 (not just SQLite), so it runs on Laravel Cloud (serverless Postgres + MySQL 8.4 LTS) out of the box. PostgreSQL is primary, but the append-only and one-active-version guarantees are enforced portably in the app layer on every engine.
Installation
composer require pushery/legal-consent-for-laravel
The service provider is registered automatically. Publish the config and run the migrations:
php artisan vendor:publish --tag=legal-consent-config php artisan migrate
Quick start
-
Write your texts as Markdown with frontmatter at
resources/legal/{type}/{locale}.md:--- version: "1.0.0" title: Nutzungsbedingungen ui_wording: Ich akzeptiere die Nutzungsbedingungen. --- # Nutzungsbedingungen …
versiondrives the ledger and decides what counts as a major change. Two optional keys,announce_atandenforce_at(oreffective_at), let the text itself carry its schedule — the publish flags below override them. How a change is classified is never taken from the file: that is a decision you make per publish, at the command line. -
Publish a version (freezes it into the ledger; you must classify the change):
php artisan legal-consent:publish terms de --material
An initial version takes effect at once — there is nothing to give notice of yet. A later material change is a different act: it must be announced ahead of its effective date, with the advance period its regime requires (see Change classes & notice modes).
-
Give any model a consent ledger with the trait:
use Illuminate\Foundation\Auth\User as Authenticatable; use Pushery\LegalConsent\Concerns\HasLegalConsents; class User extends Authenticatable { use HasLegalConsents; }
-
Enforce re-consent by adding the middleware to your authenticated routes:
// bootstrap/app.php ->withMiddleware(function (Middleware $middleware): void { $middleware->appendToGroup('web', \Pushery\LegalConsent\Http\Middleware\EnsureLegalConsent::class); })
It redirects to your
legal.consentroute (or returns409 legal_consent_requiredfor JSON) when a mandatory document has an outstanding new major version.
Recording consent (three ways)
All three write the same ledger through one recorder and share an idempotency flag, so they never double-write.
Way A — Fortify CreateNewUser trait (strongest proof context):
use Pushery\LegalConsent\Concerns\RecordsRegistrationConsent; class CreateNewUser implements CreatesNewUsers { use RecordsRegistrationConsent; public function create(array $input): User { Validator::make($input, [/* … */] + $this->consentRules(), $this->consentMessages())->validate(); $user = User::create([/* … */]); $this->recordRegistrationConsent($user, $input); return $user; } }
Way B — the Registered event listener (no Fortify): it is registered
automatically; toggle it with legal-consent.registration.listen_to_registered_event.
Way C — the headless JSON API (opt-in via legal-consent.routes.api):
POST /legal/consent { "document_key": "newsletter" } → 201
POST /legal/withdraw { "document_key": "newsletter" } → 204 (422 if not withdrawable)
POST /legal/object { "document_key": "terms" } → 201 (a Widerspruch)
POST /legal/terminate { "document_key": "terms" } → 201 (a free termination)
GET /legal/status → 200 per-document status
Or use the facade / manager directly:
use Pushery\LegalConsent\Facades\Consent; use Pushery\LegalConsent\Support\ConsentContext; use Pushery\LegalConsent\Enums\ConsentMethod; Consent::accept($user, 'terms', ConsentContext::forMethod(ConsentMethod::SettingsToggle)); Consent::withdraw($user, 'newsletter', ConsentContext::forMethod(ConsentMethod::SettingsToggle)); $outstanding = Consent::outstanding($user); // documents still owed $history = Consent::history($user); // Art. 15/20 export payload
Objecting and terminating
Two rights need their own record, because a change can be answered without accepting it:
// A Widerspruch: rebuts a deemed-consent change (§ 308 Nr. 5 lit. a BGB) — the subject is // then never deem-accepted — or objects to legitimate-interest processing (Art. 21 GDPR). Consent::object($user, 'terms', ConsentContext::forMethod(ConsentMethod::SettingsToggle)); // The free right to terminate before a change takes effect (§ 675g / § 327r Abs. 3 BGB, P2B). Consent::terminate($user, 'terms', ConsentContext::forMethod(ConsentMethod::SettingsToggle));
Each appends one immutable ledger row and fires an event — ConsentObjected or
ConsentTerminated — because only your app can act on it: the package cannot know which
processing an Art. 21 objection must stop, or how your contract ends. Listen and act:
Event::listen(ConsentObjected::class, function (ConsentObjected $event): void { // Stop the objected-to processing for $event->consent->subject, where no other basis applies. });
The delivery proof
Every pushed change notice writes an append-only legal_notices row (unless you turn
durable_medium.proof off): who was informed, when, on which medium, the exact text sent, its
hash, and whether the notice carried its mandatory content. That is the evidence a durable-medium
notice actually went out (CJEU C-375/15) — audit it like any model:
use Pushery\LegalConsent\Models\LegalNotice; $proof = LegalNotice::query()->where('document_key', 'terms')->latest('sent_at')->get(); $deficient = LegalNotice::query()->where('mandatory_content_ok', false)->exists(); // should be false
The proof is append-only and holds personal data, so legal-consent:prune covers it on the
same retention rule as the consent ledger: superseded and orphaned rows past the period go,
while the newest notice per subject and document is kept — it is what proves the change
behind their current standing was lawfully announced.
Change classes & notice modes
A change to a legal text is not one thing. The package models the four ways a change is
communicated and enforced — NoticeMode — so a change gets exactly the notice the law
requires and never more:
| Notice mode | When to use it | The subject must… | On silence | Gates access? |
|---|---|---|---|---|
SilentEditorial (--editorial) |
a typo, a clarification, a purely favourable change | nothing | bound | no |
InfoPush (--info) |
a material change that is information only — a payment-contract change under § 675g, a material privacy-notice update, a reference-rate change | nothing (may object or terminate) | takes effect regardless | no |
DeemedConsent (--deemed) |
a minor/peripheral contract change where silence may bind (Zustimmungsfiktion) | object before the deadline, else nothing | silence binds (§ 308 Nr. 5 BGB) | no |
ActiveReconsent (--active, or the legacy --material) |
a material/core contract change, or a new/expanded real consent | actively accept before it applies | not bound; the old terms continue | yes |
legal-consent:dispatch-notices routes each mode to the matching notification —
LegalChangeInformational ("no action required"), DeemedConsentNotice (carrying the
§ 308 Nr. 5 lit. b "silence counts as consent" warning), or ReconsentRequired — and, with
durable_medium.proof on, writes an append-only row to legal_notices proving the notice was
delivered. Only ActiveReconsent ever blocks access.
The invariants (never violated)
- A privacy notice is acknowledged, never consented, and is never gated — a material
privacy update is
InfoPush, announced but non-blocking (WP260 rev.01). - A real consent is never refreshed by silence or deemed consent; a material scope change needs a fresh, active opt-in (EDPB 05/2020).
- Deemed consent lives only in the contract type, and only for minor changes (BGH XI ZR 26/20, EuGH C-287/19).
- Advance-notice periods are per regime, never one global value (see
notice_periods).
Worked example — an info-only contract change ("no action required")
A payment-contract change your users only need to be told about:
php artisan legal-consent:publish terms de --info --regime=psd2_675g \
--announce-at=2026-07-01 --enforce-at=2026-09-01
Once the announcement date passes, dispatch-notices emails each affected subject a
LegalChangeInformational ("we've updated our contract; no action is required"), records a
durable-medium proof row, and the change takes effect on the effective date — no one is
blocked. Render the dismissible heads-up banner from ConsentBanner:
@php($banners = app(\Pushery\LegalConsent\Support\ConsentBanner::class)->forSubject($user, app()->getLocale())) @include('legal-consent::consent-banner', [ 'pending' => $banners['reconsent'], 'informational' => $banners['informational'], 'deemed' => $banners['deemed'], 'consentUrl' => route('legal.consent'), ])
Worked example — deemed consent with an objection window
A minor contract change where silence may bind, provided the subject gets an adequate window to object and the § 308 Nr. 5 lit. b warning.
The statutory period is measured to the objection deadline, not to the effective date — the
subject must have the full period to actually object — and it is never waived, not even for an
immediate change. So the deadline must be at least deemed_consent_min_days after the
announcement, and the change takes effect after it:
php artisan legal-consent:publish terms de --deemed --regime=bgb_agb --offers-termination \
--announce-at=2026-07-01 --objection-at=2026-08-30 --enforce-at=2026-09-01
dispatch-notices sends a DeemedConsentNotice carrying the warning and the free-termination
right. A subject who does nothing is bound: once the objection deadline passes,
legal-consent:close-objection-windows (hourly, auto-scheduled) records a DeemedAccepted
row — silence, made provable. A subject who objects or terminates in time is recorded
instead, and the fiction never applies to them.
Active re-consent (a material core change)
A material change to the core of the contract still requires active agreement, with the full grace period and a hard gate at the deadline:
php artisan legal-consent:publish terms de --active \
--announce-at=2026-07-01 --enforce-at=2026-09-01
Both dates are validated — you cannot smuggle a short lead by omitting --announce-at, and
the minimum period is per regime (§ 675g's two months is hard). From enforce_from, the
middleware blocks the subject until they re-accept the new major version. A SHA-256 hash
detects any text change; a human classifies it (the notice mode) at publish time; the
gate compares major_version only.
Content sources
Each document declares a source in config/legal-consent.php:
markdown(recommended) — git-diffable, PR-reviewable.mdfiles.database— the activelegal_documentsrow (edit via your own admin UI).cms— a foreign CMS. ImplementPushery\LegalConsent\Content\CmsResolver(or pass an inline closure) that returns aRawDocument; the package never learns what a "page" is.
Commands
| Command | What it does |
|---|---|
legal-consent:publish {key} {locale?} --editorial|--info|--deemed|--active |
Freeze the current source into a new active version under a notice mode (--material is the legacy alias of --active). Flags below. |
legal-consent:check-drift {key?} {locale?} |
Non-zero exit when a source has drifted from its published version (CI/cron). Narrow it to one document/locale with the optional arguments. |
legal-consent:dispatch-notices |
Notify subjects of a due legal change, routing by notice mode (hourly, idempotent, auto-scheduled). |
legal-consent:close-objection-windows |
Bind silence to deemed acceptance once a deemed-consent objection window closes (hourly, idempotent, auto-scheduled). |
legal-consent:prune |
Delete records past the retention period (default 3 years); the current standing of an active subject is always kept. |
legal-consent:cache-flush {key?} {locale?} |
Flush cached, rendered documents. |
legal-consent:verify-ledger |
Verify the tamper-evidence hash chain (non-zero exit on a break); only when tamper_evidence is on. |
legal-consent:publish flags
Exactly one mode flag per publish; the rest are optional metadata.
| Flag | What it means |
|---|---|
--editorial |
Silent activation — a typo/formatting fix that changes no obligation. No notice. |
--info |
Actively announced, no action required, takes effect regardless. |
--deemed |
Silence counts as acceptance. Contract/terms only; requires --objection-at. |
--active (--material) |
The subject must actively accept before it applies. |
--regime= |
The legal regime the change falls under: bgb_agb, psd2_675g, dcd_327r, gdpr, p2b, or eecc. It selects the statutory advance period, so an unrecognised value is refused rather than quietly given the generic default. |
--announce-at= |
ISO date the subjects are notified. Defaults to now. |
--enforce-at= |
ISO date enforcement begins. Omit for an immediate publish. |
--objection-at= |
ISO date the objection window closes (--deemed). Must leave the full statutory period after the announcement. |
--offers-termination |
The notice offers a free right to terminate before the effective date (§ 675g / § 327r). |
--keeps-unmodified |
The subject may keep the unmodified version (the DCD / § 327r escape hatch). |
--change-class= |
A free-form classification tag from your legal review (e.g. agb_minor_peripheral), stored with the version for the audit trail. |
A major version bump forces its mode. The publisher refuses --info/--deemed on a
major bump of a contract — a material core change cannot ride on silence or mere information
(BGH XI ZR 26/20) — and refuses anything but --info on a major privacy bump, because a
privacy notice is acknowledged, never gated. Publish a change that needs --info/--deemed
as a minor or patch bump of the source version.
UI
The core is headless — it renders and proves, and never forces a UI framework on you. Three levels, pick one:
-
Plain Blade stubs (default, 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
-
Livewire components (opt-in) — reactive, drop-in versions of the interactive screens:
<livewire:legal-consent.reconsent-form />and<livewire:legal-consent.consent-settings />— one-click accept, withdraw (Art. 7(3)), object, and terminate. They register automatically only if you havelivewire/livewireinstalled, so the package stays dependency-free otherwise. -
WireKit-flavored variant — if your app uses WireKit, publish the WireKit-themed stubs to override the plain ones:
php artisan vendor:publish --tag=legal-consent-wirekit
These are a themed starting point; a fully WireKit-native companion package is planned.
Every variant bakes in the non-negotiable anti-dark-pattern rules: checkboxes are never
pre-checked (Planet49 C-673/17), a real consent is never required (Kopplungsverbot
Art. 7(4)), and 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 seven locales 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 all seven.
Configuration
Everything lives in config/legal-consent.php. The keys you are most likely to touch:
| Key | Default | What it does |
|---|---|---|
documents |
[] |
The registry: each key maps to its legal_basis (contract / acknowledgement / consent) and source. |
default_locale · locales |
de · [de, en] |
The primary locale and the allowed set (publishing an unlisted locale is refused). |
fallback_locale |
de |
When a document is unpublished in the requested locale, fall back to this one instead of failing. |
retention_after_end |
3 years |
How long proof is kept before legal-consent:prune removes superseded/orphaned records. |
cache.store · cache.ttl |
app default · 86400 |
Where/how long rendered documents are cached (self-invalidates on a content change). |
notifications.channels |
[mail, database] |
Channels for the change notifications. |
notice_periods |
per regime | Minimum advance-notice days per regime/mode (active_reconsent_min_days, deemed_consent_min_days, psd2_min_days, dcd_termination_days, p2b_standstill_days, eecc_min_days, privacy_advance_days). A scheduled gating change is measured announcement → effective date; a deemed-consent change is measured announcement → objection deadline (the subject must have the full period to object) and is never exempt. Too short a period is refused. A per-document min_lead_days in documents may raise a period, but never undercut a statutory floor: § 675g's two months is hard — neither an override nor this config can talk it down. |
durable_medium |
proof: true, channels: [mail] |
Whether the notice dispatch writes an append-only legal_notices proof row, and on which durable-medium channels. |
schedule.dispatch_notices · schedule.close_objection_windows |
true · true |
Whether the two sweeps are auto-registered on the scheduler. |
middleware.allowlist_routes · middleware.allowlist_paths |
[] · [] |
Extra route names, and extra URI paths (wildcards allowed, e.g. billing/*), the gate never blocks. The consent route and logout are always allowed. |
routes.consent_name · routes.consent_path |
— · /legal/consent |
Where to send a subject to act: consent_name is a route name (preferred — it survives a path change), consent_path the fallback URL. Used by the middleware redirect and by the link in every change notice, so a wrong value points the legally-required notice at a dead URL. |
routes.api · routes.api_prefix · routes.api_middleware |
false · legal · [api, auth] |
Whether the headless JSON API is registered, under which prefix, behind which middleware. |
markdown.html_input · markdown.allow_unsafe_links · markdown.max_nesting_level |
strip · false · 10 |
CommonMark hardening for rendering document sources. Loosen html_input only for sources you fully control — a legal text is rendered into your users' browsers. |
Optional features (each off by default)
-
tamper_evidence(false) — turn on the append-only hash chain. Every new ledger row links to the subject's previous one, so a later edit, deletion, or reorder is detectable. Audit it withphp artisan legal-consent:verify-ledger(non-zero exit on a break); combine with the DB append-only trigger for the strongest guarantee. -
age_gate(enabled: false,threshold: 16) — Art. 8 DSGVO. When on, registration additionally requires anage_confirmedattestation. The package gates on the attestation; verifying the actual age stays your app's job. -
tenancy(enabled: false,column: tenant_id) — scope documents and consents per tenant. Register a resolver in a service provider'sboot():app(\Pushery\LegalConsent\Support\TenantContext::class) ->resolveUsing(fn () => auth()->user()?->tenant_id);
Each tenant gets its own active version of a
(key, locale); admin sweeps (prune, dispatch-notices) run across all tenants.
Upgrading
0.2.x → 0.3.0
Backward-compatible: existing code, config, and ledger rows keep working. Run the migrations and you are done.
php artisan migrate
What they do:
- Add the notice-model columns to
legal_documents(notice_mode,change_class,regime,notice_period_days,offers_termination,keeps_unmodified_offered,objection_deadline,objection_closed_at). Existing versions are backfilled: one that required re-consent becomesActiveReconsent, one that did not becomesSilentEditorial— the behaviour they already had. - Create the append-only
legal_noticesdelivery-proof table. - Drop the
legal_consents.document_idforeign key (PostgreSQL/MySQL only). ItsON DELETE SET NULLfought the append-only guarantee: deleting a supersededlegal_documentsrow aborted on PostgreSQL and silently rewrote a ledger row on MySQL. The column and thedocument()relation stay; the relation resolves to null once the document is gone. Present since 0.1.0 — this is a fix, and nothing you must act on.
Then, optionally:
--materialstill works; it is now an alias of--active. New publishes can classify more precisely with--info,--deemed, or--editorial.legal-consent:close-objection-windowsis auto-scheduled (disable viaschedule.close_objection_windows). It only does anything once you publish a--deemedchange.requires_explicit_optinwas removed from the published config'sdocumentsentries. It was never read — the value is derived fromlegal_basis. Delete it from your published config if you like; leaving it is harmless.
Testing
composer test
Security
Please review the security policy and report vulnerabilities privately rather than opening a public issue.
Built by Pushery
This package is built and maintained by Pushery — a Berlin-based studio building Laravel applications, SaaS products, and open-source tools.
Building a Laravel UI? WireKit, Pushery's open-source Livewire component kit, gives you a polished component library out of the box. Browse the rest of our work at pushery.com.
License
The MIT License (MIT). See LICENSE for details.