Skip to content

Latest commit

 

History

History
2149 lines (1466 loc) · 148 KB

UPGRADE.md

File metadata and controls

2149 lines (1466 loc) · 148 KB

Upgrade Guide

This guide documents the changes you need to make when upgrading between breaking versions of pushery/legal-consent-for-laravel. Because the package is still 0.x, a minor bump may contain breaking changes (SemVer 0.y.z).

0.45.0 → 0.46.0

This release requires league/commonmark 2.10.2 or later. Every release below it carries the table extension's denial-of-service advisory, and those below 2.9.1 the core parser's as well, both on the path every document renders through. If your lock holds an older release, update both together: composer update pushery/legal-consent-for-laravel league/commonmark --with-dependencies. If your own composer.json caps league/commonmark below 2.10.2, lift the cap first.

0.44.0 → 0.45.0

Run php artisan migrate. Migration 000039 adds a guard on PostgreSQL that refuses a TRUNCATE of legal_documents while a consent points at one of its versions, and of legal_change_sets or legal_change_items while they hold a published change description. On MySQL and SQLite it changes nothing. Migration 000040 adds an index on legal_documents on every engine, for the reads that know the tenant and the locale. If you publish the migrations instead of loading them, publish the new ones first: php artisan vendor:publish --tag=legal-consent-migrations.

A test suite that empties the tables with DatabaseTruncation on PostgreSQL can meet that guard. It truncates the tables one at a time, and the guard refuses legal_change_items while a test has left a published change description behind, as the delete guard on SQLite already does. Tests that run inside a transaction, with RefreshDatabase or LazilyRefreshDatabase, are not affected.

If you lint the migrations with sqlens and published them, publish them again. Migrations 000033 and 000036 now carry the attribute that tells sqlens on which drivers they write nothing: php artisan vendor:publish --tag=legal-consent-migrations --force. A database that ran them needs nothing, because the SQL they apply is unchanged.

A listener of LegalDocumentPublished runs after the release commits. The event used to be dispatched inside the release transaction, so an exception a listener threw rolled the whole release back. It now waits for the commit: a listener sees only versions that were released, and an exception it throws leaves the release in place. If a listener of yours relied on stopping a release, check it before you publish.

Publish the views again if you published them. The withdraw and grant buttons of the consent settings screen and the release button of the legal-text overview now name the call of their own control as their loading target, so only the control whose action is running reports itself busy, and a screen reader no longer hears every row of the list as busy. The change-class field of the legal-text editor now takes at most 48 characters, the width of the column the class is frozen into. While a queued translation runs, the editor polls pollTranslation, which answers without a render until the run ends; a copy published earlier still polls with a full render every three seconds, which works and costs the draft reads on each tick. The WireKit legal-text overview draws each language's state now when no editor route is configured, and a WireKit copy of it published from 0.36.0 on leaves every cell empty in that case. A view published before this release keeps the old markup until it is published again: php artisan vendor:publish --tag=legal-consent-views --force, and php artisan vendor:publish --tag=legal-consent-wirekit --force for the WireKit variant. A copy you changed keeps your changes only if you carry them over.

Three translation keys that no view reads are gone: ui.admin_release, ui.admin_edit and ui.admin_edit_for. A copy of the legal-text overview published before 0.36.0 reads ui.admin_release as the heading of its release column, and one published before 0.19.0 reads the other two for the edit link in each cell. On this release such a copy shows the key itself in their place, and publishing the views again, as above, replaces it. An override of the three keys in lang/vendor/legal-consent changes nothing on a screen of the current views and can go.

If you published the config, three of its comments describe an older mechanism. A chain break that legal-consent:verify-ledger reports after a retention prune was not made by the prune: the prune re-links the chains it shortens and leaves a chain that fails to verify untouched, so treat such a break like any other. durable_medium.channels only names the medium the proof row records. The notice goes out on notifications.channels, and the row is written when the mail channel has sent it. And cache.enforceable_ttl does not delay a scheduled enforce_from, which takes effect on time: it bounds how long an is_active change made outside a publish stays unseen. None of these behaviors changed in this release, so the values in your copy need no change.

The rule for a voluntary consent's control is an object now. RegistrationRules::required(), and with it consentRules(), returned ['nullable', 'boolean'] for a voluntary consent and returns ['nullable', new CheckboxAnswer] now (Pushery\LegalConsent\Support\CheckboxAnswer), which also takes the on a checkbox without a value attribute submits. Passing the rules to Validator::make() or $request->validate() needs no change. Code that joins them into a string with implode('|', …) or compares them as strings has to handle the object.

A ConsentContext you build yourself is checked when it is built. A source over 32 characters, a requestId over 64, a userAgent over 65,535 bytes or an ipAddress that is not an IPv4 or IPv6 address throws InvalidConsentContext. PostgreSQL and MySQL refused such a value at the write already; on SQLite it was stored, so a test suite on SQLite can meet the refusal for the first time. Pass null for an address you do not have.

A change description is checked when it is described. A subject, partyName, partyLocation or partyContact longer than the default string length, 255 characters unless your application sets another, or a headline, impact, detail or purpose over 65,535 bytes throws InvalidChangeDescription from the ChangeItems builder. MySQL refused such a value at the save already, and PostgreSQL the long one; SQLite stored it, so a test suite on SQLite can meet the refusal for the first time.

A class of your own that implements Pushery\LegalConsent\Contracts\ConsentManager gains two methods, owed(Model $subject, ?string $locale = null, bool $firstAcceptance = false): Collection and hasCurrentMany(Model $subject, array $documentKeys, ?string $locale = null): array. EnsureLegalConsent asks the first in place of outstanding() and firstAcceptance(), so a manager of your own that lacks it fails on the first request the middleware guards, and hasAcceptedCurrentLegalMany() on your model asks the second. The package's own manager and Consent::fake() have both. A class that decorates the package's manager can pass the calls through.

Under Consent::fake(), hasAcceptedCurrentLegalMany() answers what hasAcceptedCurrentLegal() answers for each key. It read the fake's statusIs() arrangement, so a fresh fake answered false for every key while the single-key method answered true. Arrange it with owes() as you arrange the single-key question.

With tamper evidence on, pin the session time zone of your database connection. The chain hashes accepted_at as the database renders it, and on PostgreSQL and MySQL that follows the session time zone. Laravel sets it only from a timezone key on the connection, which Laravel's own pgsql and mysql connections do not carry, so without one the server decides, and a change of its setting makes legal-consent:verify-ledger report every chained row as broken. Set the key to the zone your chained rows were written under: SHOW TimeZone on PostgreSQL and SELECT @@session.time_zone on MySQL report the one in effect now. legal-consent:doctor warns while the key is missing.

A queued translation that outlasts the worker's timeout is not tried again. TranslateLegalDraft fails on its timeout now, so the editor says at once that the translation did not finish, where a worker started with --tries above 1 used to run the translator again on each attempt while the editor kept saying it was running. If you raised --tries to give a slow translator another chance, give its lane a longer --timeout instead: translation.connection and translation.queue_name put the job on a worker of its own.

ConsentBanner::pendingFor() and deemedFor() take $accepted by value. They used to fold the subject's holdings into the variable you passed and hand it back for the other banner to reuse. They now read only the keys their own window concerns and leave your variable as it was, so a map handed on from one to the other could no longer be missing the keys the second one asks about. Code that reused it pays one more read and gets the same answer. forSubject() shares one read between the two banners itself.

An empty or missing legal-consent.locales means the default locale alone, everywhere. The publisher allowed any locale for it, while the admin screens and the document route knew none and the commands took the default locale. If your configuration leaves the list empty and you publish in more than the default locale, list those locales: a publish to another one is refused now. A release that names no locale at all throws InvalidArgumentException.

0.43.1 → 0.44.0

A later version of a major now keeps what the major asked for, and the first sweep after the upgrade may send notices. The gate, the banner, the notice sweep and legal-consent:close-objection-windows used to read the mode of the active version alone. If you published an editorial fix or an info-only change inside a major that went out as an active re-consent, everyone who has not accepted that major is asked again after the upgrade. If you published a correction while a change's notices were still going out, the notices that change still owes go out with the next sweep, and an objection window left open by a correction closes with the next run of close-objection-windows. Run php artisan legal-consent:dispatch-notices --dry-run before the first sweep to see who will be written to.

legal-consent:rerender refuses in two more cases, and exits 1 when it does. It refuses a document whose title or acceptance sentence differs from the published version, because the sentence is copied into every consent given under it: publish a new version instead. It also waits for a version that has not taken effect yet: run it again after the version's date. A deploy script that runs it after every package update reads the refusal from the exit code and the warning lines.

Raise pushery/wirekit to ^2.56.0 if you use the WireKit views. From that release the kit's editor no longer reports its own version of a text as input when it loads, which the editor view relies on. With 2.49.x to 2.55.x installed, the default auto variant serves the plain views. A copy of the editor view published from an earlier release still works on 2.56.0; the element around the editor that held the input back is no longer needed there.

If you lint the migrations with sqlens and published them, publish them again. Five migrations changed so that sqlens:lint reads them all: php artisan vendor:publish --tag=legal-consent-migrations --force. A database that ran them needs nothing, because the SQL they apply is unchanged.

Publish the WireKit views again if you published them. The consent checkboxes and their document link changed, so a link that opens the wording dialog stops announcing a new tab, and the name of the dialog reaches its click handler as a JavaScript string: php artisan vendor:publish --tag=legal-consent-wirekit --force.

Publish the plain views again if you published them. A link inside the consent sentence now carries rel="noopener noreferrer": php artisan vendor:publish --tag=legal-consent-views --force. A copy you changed keeps your changes only if you carry them over.

A release from the admin screens no longer waits for every language of a contract or a consent. It covers the languages whose draft is reviewed and matches its source, and a language left out keeps the version it has live until its own translation is released. If your process relied on a missing translation stopping a release, review the languages the manager names beside the release button before you confirm. Nothing changes for a release from legal-consent:publish, which publishes the languages you name.

A model of your own mapped in legal-consent.models now keeps the package's write guards. If its booted() did not call parent::booted(), it had lost them: it could rewrite a ledger row or a published version. Such a write now throws LedgerImmutableException or LegalDocumentFrozenException, as it does for the package's own models.

A whole-number setting your published config reads straight from env() now applies. With 'threshold' => env('AGE_GATE_THRESHOLD', 16), the environment's value arrived as a string and the package default applied instead. Check the values you set this way before you upgrade: the age threshold, a notice period, a document's min_lead_days or a notification limit becomes the one you configured. php artisan legal-consent:doctor names a whole-number setting that holds anything else.

A tenant resolver has to answer with a tenant id. If you enable tenancy, resolveUsing() and resolveSubjectUsing() return the tenant's key as an int or a string, or null where there is no tenant; a backed enum and a Stringable count as well. Any other answer, a model among them, now throws Pushery\LegalConsent\Exceptions\UnresolvableTenant. It used to count as no tenant, which put every tenant into one shared bucket. A resolver that returns tenant() returns tenant()?->getKey() instead.

Name the shared checkbox of an informational page you flag for registration. A page with acknowledge_at_registration is now acknowledged only when the request carries its control, ticked. If the page sits in a line under another document's checkbox, set registration_field on it to that checkbox's field, legal_terms for example; otherwise its rows stop being written, and the log says which field it looked for.

Save a model before recording its consent. A consent recorded for a subject without a key, a model that is not saved yet, now throws Pushery\LegalConsent\Exceptions\SubjectWithoutKey. Before, it wrote a row with no subject_id, which nothing could find and the retention sweep later removed. If you record consent while a model is being created, do it after save() or in a created listener.

An X-Request-Id set in front of your application reaches a consent record only through a trusted proxy. The request id is recorded where the server side vouches for it: set by your application after the request arrived, in a middleware for example, or carried in through one of your trusted proxies. A header straight from a client is no longer recorded, because it could name any request. If your records should carry the id your load balancer assigns, list the load balancer in your trusted-proxy configuration.

A class of your own that implements Pushery\LegalConsent\Contracts\ConsentManager gains one method, publishedVersion(string $documentKey, ?string $locale = null): ?PublishedVersion. The package's own manager and Consent::fake() have it. A class that decorates the package's manager can pass the call through.

0.43.0 → 0.43.1

Nothing is required of you, unless you published the French translations.

The French confirmation before a consent is withdrawn reads differently. Its second sentence now reads « Cela prend effet immédiatement … », as the English one reads "It takes effect immediately …". A copy published with --tag=legal-consent-lang keeps the old sentence. Change it in lang/vendor/legal-consent/fr/ui.php, or publish again with php artisan vendor:publish --tag=legal-consent-lang --force if your copy holds no changes of your own.

0.42.4 → 0.43.0

Run php artisan migrate before anything else of the release runs. It brings migrations 000032 to 000038, and publishing and the queued change notices already read and write legal_documents.objection_min_days, the column 000038 adds.

Raise pushery/wirekit to ^2.49.0 if you use the WireKit views. The release dialog of the admin screen closes through the kit's close-on-confirm, first released in 2.49.0. With 2.47.x or 2.48.x installed, the default auto variant serves the plain views.

Publish again what you published. Three WireKit views changed, the re-consent screen, the legal text manager and the wording dialog: php artisan vendor:publish --tag=legal-consent-wirekit --force. The script changed too: php artisan vendor:publish --tag=legal-consent-assets --force.

legal-consent:prune refuses a retention period it would misread, and deletes nothing. A retention_after_end such as 3 Jahre, 3y, 12 hours or -3 years now stops the run with a message naming the value. Use a relative date that lies at least a day in the past, such as 3 years, or an ISO 8601 duration such as P3Y. legal-consent:doctor reports a value the sweep refuses.

A document in a language without an acceptance sentence is refused at publishing. The sentence used to come from app.fallback_locale and was frozen into the version. A locale the package's translations do not cover now stops the publish with MissingAcceptanceWording: add lang/vendor/legal-consent/{locale}/wording.php, or give the source its own ui_wording.

A queued listener of a ledger event reads the row back when it runs. The consent events, LegalDocumentPublished and NoticeDispatched serialize their model by its key. After the retention sweep removed the row, the job fails with ModelNotFoundException, unless the listener is marked #[DeleteWhenMissingModels].

Under multi-tenancy with a resolver that reads the signed-in user, register TenantContext::resolveSubjectUsing(). It tells the package the tenant of the account being registered, which is not signed in yet while its registration consents are recorded. A resolver that reads the tenant from the request, such as a domain, needs nothing more.

Tests with Consent::fake() see a subject and a locale as the real manager does. A subject is recorded under its morph alias, and a write without a locale records the context's locale or legal-consent.default_locale. An assertion that named the model class or expected German by default needs the alias or the configured locale.

Two settings are new. notifications.max_attempts (default 3) and notifications.requeue_after_minutes (default 1440) bound how often a notice is tried. Their defaults apply where your published configuration does not name them.

0.42.3 → 0.42.4

Nothing is required of you, unless a deploy runs legal-consent:publish --all over sources from which the file of a published translation was removed, or a document in legal-consent.documents has a key made of digits.

A translation whose file is gone now fails the run. Its readers are still shown the version published last, because a reader is answered from their own language first. The run used to report the stand-in language, which none of them saw, and exit 0. It now exits 1 and names the version those readers keep. Restore the file to publish that language again.

A document whose key is a number now reaches registration. The registration rules, the checklist and the recorder used to leave out a document registered under a key like '2025'. They include it now, so a registration form needs its control where the document is mandatory: legal_2025, unless the entry declares a registration_field.

0.42.2 → 0.42.3

Nothing is required of you, unless your application keeps its own reference to SourceFactory.

SourceFactory is no longer shared. The container builds a fresh one for each resolution, bound to the container that asked for it, so that under Octane a request's own bindings reach the content sources it builds. If you rebind SourceFactory yourself, that binding still wins. If your code resolved the factory once and kept the instance, it keeps working, but it holds the container it was resolved from.

0.42.1 → 0.42.2

Nothing is required of you, unless you published the editor views.

Three fixes live in the views. The translate button is disabled and names the wait while a translation of the draft runs, the WireKit editor holds back the input its engine reports on load, and the WireKit discard confirmation names the language. A view you published earlier has none of them; publish it again with php artisan vendor:publish --tag=legal-consent-views --force, or the legal-consent-wirekit tag for the WireKit ones. If you keep a changed copy of the WireKit editor, carry over the element around <x-wirekit::editor> with its x-data and x-on:*.capture attributes: without it, opening a text and saving it can store the engine's version of the text.

Two lines are new. admin_translate_running labels the disabled button and admin_status_translation_running answers a press while a translation runs. A language file you published earlier keeps working, because the package's own lines fill in every key an override does not carry.

0.42.0 → 0.42.1

Nothing is required of you, unless you published the editor views.

The editor offers a release with an objection window only for a contract's terms. The component passes offersDeemedRelease as false for a privacy notice and a consent now, since the publisher refuses a deemed change for both. A view you published earlier reads the value only if it was published from 0.41.2 on; publish it again with php artisan vendor:publish --tag=legal-consent-views --force, or the legal-consent-wirekit tag for the WireKit ones.

The admin screens word the publisher's refusals themselves. LegalPublishRefused gains a reason (a PublishRefusal, or null) and the values it was built from; its message is unchanged. If your own screen shows these refusals, the new LegalPublishRefused::$reason and the legal-consent::ui.publish_refused_* lines let it word them the way the package's screens do. A language file you published earlier keeps working, because the package's own lines fill in every key an override does not carry.

0.41.2 → 0.42.0

Nothing is required of you, unless you release with an objection window from the editor, or you published the editor views.

A release with an objection window needs all three dates and a regime. The editor used to leave an empty announcement date to the publisher, which took the moment of the click, and a change without a regime was measured against the tunable default alone. It now refuses the release and names every empty field. Fill in the announcement date, the objection deadline, the effective date and the regime; the change class and the two switches stay optional. The shipped views mark the four fields as required. A view you published earlier does not, and the refusal still names them; publish it again with php artisan vendor:publish --tag=legal-consent-views --force, or the legal-consent-wirekit tag for the WireKit ones.

Three lines are new. admin_status_deemed_window_incomplete names the empty fields and admin_status_deemed_dates_unreadable the dates the editor cannot read, both with a :fields placeholder. notice_timeline_inverted words a window whose announcement falls after its effective date, with :document, :announce and :enforce. A language file you published earlier keeps working, because the package's own lines fill in every key an override does not carry. Add the keys there only for wording of your own.

An informational page is released in the languages that are ready. A translation that is written but not reviewed, or whose source moved on after its review, no longer holds the page back: that language keeps the version it has live. When the source is not ready, the release covers the source alone. If your own screens call LegalDraftSet::releaseLocales(), they get the same answer. An acknowledgment with locale_fallback is released the same way.

A release replaces a version number that was already used for another text. A number on the source draft that a released language already carries with other text used to be refused; the release now derives the next one instead. A number you set with LegalDraftWriter::setVersion() that no release has used yet is kept.

0.41.1 → 0.41.2

Nothing is required of you, unless you published the editor views. One fix, in the text editor.

The editor leaves the objection-window form out for an informational page. The publisher refuses a release with an objection window for a document with legal_basis => 'informational', so the form could only fail there. The editor now passes its views offersDeemedRelease, and both shipped views render the form only when it is true. A view you published earlier does not read the value and keeps showing the form; publish it again to take the fix, with php artisan vendor:publish --tag=legal-consent-views --force for the plain views or the legal-consent-wirekit tag for the WireKit ones.

0.41.0 → 0.41.1

Nothing is required of you. One fix, and it needs no configuration.

A queued translation now runs as the admin who asked for it. With translation.queue on, the job carries the identifier of the acting admin and the guard the editor read them from, and signs that user in for the call to your LegalTextTranslator alone. A translator that bills or limits per user therefore reads the acting user on a worker the way it does on a request; it read nobody before. LegalTextTranslator is unchanged, so no implementation of it has to move. An admin who is gone by the time the worker runs, or a job queued before this version, translates unattributed rather than failing.

0.40.0 → 0.41.0

Nothing is required of you. One new setting, inert until you set it.

You can replace the package's models with your own. Map one of the six to your subclass in legal-consent.models, keyed by the package class, and the package uses it on every path — every query, every row it writes, and the relations between its models:

'models' => [
    \Pushery\LegalConsent\Models\LegalConsent::class => \App\Models\LegalConsent::class,
],

Keep the table, the tenant scope and the mass-assignment guard your subclass inherits. These rows are the proof of what a subject was told and agreed to, and the package writes them only through its own curated paths.

The six models are no longer final, and each names its table. The names are the ones the migrations have always created, so there is nothing to migrate. If you referenced a model's table by deriving it from the class name, it is unchanged.

0.39.0 → 0.40.0

Nothing is required of you. Two new settings, both inert until you set them, and one of them is worth setting today.

Name the routes where a subject exports or deletes their data. While a subject owes a document, the gate stops every route it guards, and until now that included these. Access, portability and erasure do not depend on accepting a new text:

'middleware' => [
    'allowlist_routes' => [],
    'allowlist_paths' => [],
    'rights_routes' => ['profile.edit', 'profile.export'],
],

Name the page that holds the button as well as the action behind it: a Livewire action on a gated page is unreachable even though its endpoint is not. legal-consent:doctor now notes a gate with none named, and exits 0 on that, because only you can know whether those requests are served outside the routes the gate guards. A name that is no route fails it.

An acknowledgment can fall back to another language. Set 'locale_fallback' => true on its entry and a reader of an untranslated language is shown the version the gate already holds them to, and a release covers the languages that have a text. It is refused on a contract and a consent, and the doctor fails on an entry that asks.

The dialog names the language of a text that is not the reader's. If you published the fragment stub, your copy shows no such line until you port the $shownIn block from the package's view. Nothing breaks either way.

The wording dialog asks the fragment route with heading=0, so the document's title appears once, in the dialog's header. If you published the WireKit stub, your copy keeps the title twice until you add 'heading' => 0 to its two route('legal-consent.document.fragment', …) calls.

legal-consent:publish --all no longer fails over a language whose reader is shown another one's version. A deploy that tolerated its exit 1 for an informational page published in one language turns green; the language is still named in the output.

The doctor no longer lists an informational page's untranslated languages under "no published version", because their readers are shown the default-locale version. A deploy step that searched its output for them stops seeing them.

0.38.0 → 0.39.0

Two things are required of you, and only if the wording dialog is switched on (ui.wording_dialog together with routes.fragment). Nothing changes for an installation that leaves it off, which is the shipped default.

Re-publish the dialog's script

The dialog filled its body with x-html, and Alpine's CSP build refuses that directive outright: Using the x-html directive is prohibited in the CSP build. Every release since the dialog shipped in 0.36.0 opened onto an empty box under that build, and the link that opened it had already swallowed the click, so the reader could not reach the text at all. The view now leaves the body to the script, which puts the published text in through a ref.

A published script is a copy, so composer cannot bring this one to you:

php artisan vendor:publish --tag=legal-consent-assets --force

Until you do, the dialog opens without its text, under either build. The view no longer carries x-html, and the old script only ever set the value x-html read. The dialog's footer now carries a link that opens the document as a page, so the text stays reachable in the meantime — but that is the way out, not the dialog working.

So the next change to the script cannot catch you the same way, publish it on every update:

"scripts": {
    "post-update-cmd": [
        "@php artisan vendor:publish --tag=legal-consent-assets --force"
    ]
}

Re-publish consent-checkboxes.blade.php, or port the changes into your copy

Only if you published the WireKit stub. Your copy still carries x-html, so it stays empty under the CSP build, and with the new script it reports that the text could not be loaded under either build, because the ref the script fills is not there.

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

--force overwrites your file. If you changed it, port the changes by hand: x-ref="body" in place of x-html="body" on the dialog body, the url and locale entries in the $deferredDialogs collector, and the page link in the dialog's footer, in front of <x-wirekit::modal.close>. A grouped control also gives each member a dialog of its own now: the $memberDialog, $memberDialogName and $memberTrigger closures beside $memberLinkId, the trigger on each member link, and the loop after the collector that adds a dialog per member. Without that part your copy keeps working as before, with member links that go to the page.

The failed state is one sentence now. legal-consent::ui.dialog_failed used to add that the link beside it opens the page, which was not true: that link is the one that opened the dialog. If you published the language files, shorten the entry and add dialog_open_page, the footer link's text.

0.37.0 → 0.38.0

One thing is required of you, and only if you publish the WireKit consent stub.

Re-publish consent-checkboxes.blade.php, or port one change into your copy

The WireKit stub used to render each document's dialog inside the field stack, where it cost a full flex gap per document — the wrapper is a visible child of height zero, and a flex container gives one its whole gap anyway. The dialogs are now collected and emitted after the stack, inside a root element that has no gap.

Publishing froze your copy, so composer cannot bring this to you:

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

--force overwrites your file. If you changed it, diff first and port the three pieces by hand instead: the $deferredDialogs array declared beside $bindTo, the collector that replaces the in-loop <x-wirekit::modal> block, and the loop that emits them after </x-wirekit::stack> inside the new root <div class="legal-consent">.

Nothing else changes. A dialog is addressed by name, so moving it changes nothing about opening one, and the root element replaces the stack as the view's single child — a caller still sees one child, and legal-consent-fields stays where your CSS expects it.

0.36.1 → 0.37.0

Nothing is required of you. Everything below is opt-in or a wording change, and an installation that changes nothing behaves exactly as it did.

A document may name the form field that evidences it

If your registration form shows ONE control for several documents — "I accept the terms and have read the privacy policy" is the ordinary shape — you can now say so per document, and the validation rules, the checklist your form is built from and the evidence check will all ask for the control you actually rendered:

'terms'   => ['legal_basis' => 'contract',        'registration_field' => 'legal_terms'],
'privacy' => ['legal_basis' => 'acknowledgement', 'registration_field' => 'legal_terms'],

Before this, such a form left you two options and both were bad: render a field nobody sees, or accept recorded a mandatory consent with no registration-form field present on every single registration, under a hint that names a completely different cause. If you were living with that warning, this is what removes it.

Documents behind one control must agree on their rule. A mandatory document and a real consent cannot share one — the first needs accepted, and requiring the second is the coupling Art. 7(4) forbids — and such a registry is now refused where it is read, with both keys named. If you get that exception, give the consent its own control.

The hidden content-hash field is NOT renamed. It stays legal_{key}_hash, because it carries one document's version fingerprint: four pages behind one checkbox render one control and, if you want the accept-time guard, four hidden fields.

The release overview stops arming a button over a refusal

For a document whose text does not come from the draft store ('source' => 'markdown'), the overview used to report readiness from drafts, review and staleness alone — so with reviewed drafts present it said ready, armed the release button, and pressing it produced the refusal the releaser had all along. Both now read one predicate.

The no-draft row changes its wording as well, deliberately: it used to say "no draft yet", which is true and points at the wrong thing, because writing one does not help. It now names the source. If you have built a screen on BlockingReason, expect NotDraftBacked where you previously saw NoDraft for these documents.

The matrix legend explains its two review words

Two sentences under the legend, in both trees, saying what "Draft" and "Reviewed" mean — the second half of the first being the one that matters: reviewing publishes nothing. Override either with an empty string to drop it:

// lang/xx/ui.php
'review_state_reviewed_description' => '',
'review_state_draft_description' => '',

0.36.0 → 0.36.1

One thing is required of you, and only if you turned the wording dialog on in 0.36.0.

The dialog's Alpine component is published now, and has to be loaded

The dialog shipped with its logic written inline in the template. Alpine's CSP build parses those attributes with its own grammar rather than eval, and that grammar refuses arrow functions, optional chaining and several statements in one attribute — all of which that expression had. On an installation with a policy without unsafe-eval the dialog therefore opened onto nothing, while the link was announced to a screen reader as opening a dialog.

The logic moved into a component that is published and loaded as a file, which is also what such a policy expects:

php artisan vendor:publish --tag=legal-consent-assets
<script src="{{ asset('vendor/legal-consent/legal-consent.js') }}" defer></script>

Before Alpine, and that is all — it registers itself on alpine:init and needs no bundler.

If you leave it out, the dialog opens without its text. The anchor still carries the document's real address, but wherever Alpine runs it swallows the click to open the dialog, so the address is not where the reader ends up. This said the dialog does not open and that the text stays reachable either way; measured in a browser, it opens, and the only way on is to close it. Since 0.39.0 the dialog's footer carries a link to the page for exactly this case.

Nothing to do if ui.wording_dialog is off, which is the shipped default.

0.35.0 → 0.36.0

Nothing is required of you. Two things changed that you may want, and one that removes a failure you may have been living with.

legal-consent:doctor no longer ends on an unreachable database

Four of its sections are read from the database, and until now a refused connection ended the command. If you run it in a stage that reaches no external services on purpose — a statics or lint step — it stopped there with a Connection refused that looked like a package defect.

It now names the half it could not check and carries on with the configuration comparison, which needs no connection at all. The exit code is unchanged in both directions: a key that does not reach your runtime is still a failure, and an unreachable database is not — and the second does not hide the first.

If you moved that command out of your quality stage to work around this, you can move it back. A database that is there but not migrated was never the problem and still is not: every one of those checks already answers for a half-finished installation.

A document's text can open in a dialog over the consent form

Opt-in, and it needs two switches, because the dialog fetches its text from the fragment route:

'routes' => ['fragment' => true],
'ui' => ['wording_dialog' => true],

With either one off, nothing changes and the consent sentence keeps the link it has always had. WireKit views only — the plain stub names no framework and needs no JavaScript.

The link stays a link. A consent binds only where the full text was reachable before agreeing (§ 305 Abs. 2 BGB), and a dialog opened by script is not reachable without script, so the dialog sits on top of an anchor that works without it. You do not need to change anything about how you render consent for this to be true.

0.34.0 → 0.35.0

Run php artisan migrate. Nothing else is required of you, and nothing about your existing ledger changes.

The tamper-evidence audit now covers the newest row of every chain

A row in the consent ledger was vouched for by the link its successor stores. That leaves exactly one row per person uncovered, and it is the one that matters most: the newest row is what says what somebody holds right now. The migration adds a table in which each row's own hash is recorded beside it, and legal-consent:verify-ledger checks the tail of every chain against it.

This only applies if you have tamper_evidence turned on, which is off by default. With it off, the table is created and stays empty.

Rows already in your ledger are treated as history and always will be. They were never witnessed, so no evidence exists to judge them by — the check applies from this migration onwards. The boundary between the two is recorded once, with a proof of its own, so it cannot be moved afterwards.

Until you run the migration the audit says so out loud rather than reporting a clean ledger: it prints that the check did not run and names the migration. That is deliberate — a check nobody ran must not read as a check that found nothing.

One thing to expect if you write to legal_consents outside this package. A direct INSERT that carries a valid chain link used to pass the audit; from this migration onwards it is reported, because the row carries no recorded hash. If you have a data-import path that writes consent rows itself, route it through ConsentManager or expect the audit to name those rows. An Art. 17 erasure and the retention sweep are unaffected — both record afresh for the rows they rewrite.

0.33.0 → 0.34.0

Nothing is required of you — but read the one paragraph below if you ever ran vendor:publish on this package's config.

Settings you never published now take effect

Until now the provider merged its shipped defaults with Laravel's flat mergeConfigFrom(). That asks one question per top-level block: is it already there? A published file has every block, so any key this package added inside one of them in a later release never reached you. It read as null, nothing errored and nothing logged.

The provider now merges section by section, so those keys arrive.

What that means in practice: if your published config/legal-consent.php predates a setting, that setting stops being null and starts being whatever the package ships as its default. In almost every case that is the value you would have got had you published today, and it is the reason this is a fix. In one case it is worth a look: a default that is deliberately strict — a gate, a throttle, a switch for unsafe content — was effectively off for you and is now on. If you had relied on it being absent, write it explicitly in your published file.

A list you narrowed is left exactly as you wrote it, including an emptied one. Recursion alone would not do that: array_replace_recursive() merges lists by index and would hand back entries you removed, which on routes.api_middleware would restore middleware you took off on purpose. Recursion here stops at any list.

If your config is cached (config:cache), nothing merges at all — that is the framework's design and was already true. For those installations the published file remains the whole truth.

0.32.0 → 0.33.0

Nothing is required of you. No migration, no renamed config key and no removed option — one number on the admin screen changes, and it changes because it was wrong.

The admin screens name a document instead of quoting its key

The manager's and the editor's status messages put the configuration key and the locale code into their sentences: terms (fr). The document is now named by its published row's title, then by the shipped legal-consent::titles.* catalog, then by its key. A language is still answered as its code, because nothing in this package knows that fr is called French and ext-intl is not a requirement of this package.

If an assertion of yours matches on the key in one of those messages, it matches the title now. If you have language names — a switcher usually does — bind Pushery\LegalConsent\Contracts\NamesLegalTexts and both halves of the sentence use yours.

The version of a change can be derived instead of typed

LegalDocumentReleaser::nextVersionFor($key, $mode) is new and optional. A gating change answers the next major, a change that must not ask anybody again answers the next minor of the active version, and a key nothing has published yet answers 1.0.0. It reads the highest active version across the languages, because the next version has to clear every one of them.

If you compute the next version yourself before a release, this is the same answer with the package's own reading of the gate behind it. If you do not, nothing changes: no release path calls it, and the version keeps coming from the draft row.

An informational page no longer waits for every translation

The manager's release button passed every configured locale to the releaser, and a locale without a draft blocks the whole set. For a contract, a privacy notice or an opt-in that is exactly right — nobody may be bound in a language they did not read. An imprint, a cookie notice or an accessibility statement binds nobody, and PublishedDocumentReader::fallbackFor() already serves the source language under any other locale for those rows, so a missing translation kept the page off the site for no reason the package could name.

The button now releases an informational document in the locales that have a draft. A locale whose draft is written but not reviewed still blocks, and a document with no draft anywhere still refuses with every language named.

If you narrowed the locale list yourself before calling release(), you can drop that code for informational documents. The releaser is unchanged: it still releases exactly the locales it is given, for every document type, so a CLI publish and your own screens behave as before.

The number under a release counts people, not one language

A consent row carries the language it was given in, so LegalDocumentReleaser::affects() answers per (key, locale). The manager's success line reported the first released row's count under a release covering every configured locale, and therefore named the people of whichever language came back first. In a seven-locale installation that read "affects 0 people" while one person was a major behind on the German text.

affectsRelease(Collection $released) is the count for a whole release: every language it covered, every person once. Somebody who accepted the new major in any language is not behind on it, because the major belongs to the document rather than to its translations.

If you render your own release screen, switch from affects($released->first()) to affectsRelease($released). If you sum affects() over the released rows, that counts a subject who accepted two languages twice — the new method is what you wanted. affects() itself is unchanged and stays the right call for a single (key, locale), which is the unit the notice sweep works on.

0.31.0 → 0.32.0

Nothing is required of you unless you publish a re-consent as a minor or a patch. No migration, no renamed config key and no removed option.

A re-consent has to raise the major version

The gate asks whether a subject holds a document's major version. An active re-consent published as a minor or a patch therefore reached nobody who had accepted the current major: the version said a re-consent was requested, and no earlier acceptor was ever asked for one. The publisher now refuses a gating mode on a version that keeps the active major of a contract or a real consent, and the refusal names the version to publish instead.

If you publish a re-consent as a minor or a patch today, raise the major instead: a text that has to be accepted again goes out as 2.0.0, not 1.1.0. A change that should not ask anyone again keeps its minor and goes out in a mode that does not gate: --info, --editorial, or --deemed for a minor contract change. The same holds when you publish from code with NoticeMode::ActiveReconsent or through the legacy publish(..., isMaterial: true).

A document's first publication is unaffected: nobody holds a major yet, and gating it is how the first acceptance is collected.

The legal text manager releases a privacy notice

The manager's release button used an active re-consent for every document. The publisher refuses that for a privacy notice, so the button failed for every privacy notice. It now releases each 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, silent for an informational page.

If you copied the component only to release a privacy notice, you can go back to the package's own. Nothing else changes.

The document source contract names the document key

Only docblocks changed. LegalDocumentSource::resolve() and fingerprint() receive, and RawDocument::$type carries, the name a document is registered under (terms, privacy), not a DocumentType value. If you implement your own source, nothing changes: that is what the argument always held.

0.30.0 → 0.31.0

Nothing is required of you. No migration, no renamed key, no removed option, and nothing you render changes unless you want it to.

Short forms for the admin matrix's state words

Every state the admin matrix draws now ships a short form beside its long one — admin_not_written_short and its five siblings, in all seven languages. The long forms are unchanged and still shipped: they are what the short ones are explained by.

A matrix with one column per locale is what makes this necessary. The state words are sentences, and seven columns of them fit no desktop width; the package's own screen draws the short form in the cell, keeps the long one as the badge's accessible name, and explains the six once in a legend above the table.

If you render your own matrix, you can switch to the short keys and keep the long ones for a legend. If you render the long forms today, nothing changes.

0.29.0 → 0.30.0

Nothing is required of you unless you render a refused lead time from the values the exception carries. No migration, no renamed config key and no removed option.

A refused deemed-consent lead time now names the objection deadline

LeadTimeTooShortException measures two different spans and used to call both of them enforcement. For a deemed-consent change the statutory period is the objection window, so the span ends at the objection deadline; for every other change that owes notice it ends at enforcement. An operator told the wrong word moved the wrong date and was refused again with the same number.

The exception now carries a LeadTimeSpan, and label() and replacements() follow it.

If you word the refusal yourself, read the placeholder the span names rather than enforce. For a deemed-consent refusal the end date now arrives under deadline and enforce is absent; for every other refusal nothing changes. The package's own sentences ship for both, in all seven languages, so this only concerns a host that carries its own translation:

// before — correct for one of the two spans, silently empty for the other
$date = $e->replacements()['enforce'];

// after — whichever span it was
$date = $e->replacements()[$e->span->endPlaceholder()];

$e->enforceAt still holds the date for both spans and did not change. It keeps its name because it is public readonly state you may already read; $e->span is what says which of the two it is.

0.28.0 → 0.29.0

Nothing is required of you unless a morph alias or a document key in your application is a number. No migration, no renamed key and no removed option.

A morph alias that is a number

If your morph map gives a model an alias that is a number, such as Relation::morphMap(['404' => User::class]), neither notice sweep reached a subject under it: legal-consent:dispatch-notices queued nothing for them and still stamped each version it ran over as notified, and legal-consent:close-objection-windows deemed nobody. Both sweeps reach them now.

A version stamped while this was broken is not swept again by itself. The sweep only takes active versions whose notified_at is empty. Clear it on each version those subjects are still owed a notice for, and the next run sends it.

A document whose key is a number

legal-consent:publish --all, the doctor's report of unpublished documents and legal-consent:rerender without arguments skipped a document configured under a key that is a number, such as '2024' => [...]. They include it now. If you have such a document and have held it back on purpose, the next publish --all publishes it.

0.27.0 → 0.28.0

Run the migration. php artisan migrate adds two nullable columns to legal_documents, and nothing else is required of you: no renamed key, no removed option, no published view or config that has to change.

A published version records what it was rendered from

source_hash holds the hash of the source text a version was rendered from, taken before any rendering decision touches it; render_fingerprint holds a fingerprint of the renderer that produced its HTML — the markdown options in effect, the extension list, the sanitizer allowlists and the installed CommonMark version. Both are proof columns, frozen after publish like every other one.

Rows published before the migration keep NULL in both, and that cannot be repaired. The bytes that produced them are gone, and hashing today's source would claim the text was unchanged at publish time with nothing having checked it. legal-consent:check-drift reports such a version as undecidable rather than guessing, and the next ordinary publish fills the columns in.

legal-consent:check-drift says WHAT changed

It used to answer every difference with "source differs from published vX — publish a new version and set its materiality", whether the text had moved or only the way it is rendered. It now separates the cases: the text changed; the text and the renderer changed, named apart; only the presentation changed; or the version predates the columns above. A script that greps its output for the old sentence needs the new wording — the exit code is unchanged, non-zero on any drift.

legal-consent:rerender {key?} {locale?} is new

For the presentation-only case. It freezes the identical text again under the next patch version and the silent notice mode: nobody is re-asked, no notice goes out, and no materiality decision is owed. The proof that the text is identical is the source hash, so a document whose text really changed is refused, as is one whose active version predates the column. Without arguments it walks every configured key and locale.

It writes a new row rather than correcting the old one — every consent in the ledger names the content hash it was given against — so expect a X.Y.Z+1 version to appear where you run it. The gate compares major versions, so nobody is gated by it.

0.26.2 → 0.27.0

Nothing is required of you. No migration, no renamed key and no removed option. The settings screen gains three things you can switch on and says one thing it did not say before; publish the views again only if you published them.

Every consent row says whether it is given

The settings screen shows Given or Not given beside the title of each voluntary consent, in the plain and the WireKit view and in all seven bundled languages. Without the grant button, which stays off by default, a consent the subject never gave used to show as its bare title. A screen you built on ConsentPresenter::settingsFor() is unaffected; held is the key the views read.

Three options for the settings screen

  • <livewire:legal-consent.consent-settings :heading="false" /> leaves out the screen's own heading, for a page that titles itself. The group headings then move up from h3 to h2.
  • <livewire:legal-consent.consent-settings :hide-empty-groups="true" /> leaves out a group while the subject has nothing in it, for a deployment that never publishes one kind of document. With every group empty the screen still says that nothing is published.
  • legal-consent.ui.icons.withdraw and legal-consent.ui.icons.grant put a WireKit icon on the WireKit panel's buttons. Both default to null, which renders the buttons as before; a config you published earlier needs the icons block only if you want them.

Published views keep their old markup

The WireKit consent checkbox now shows its wording inside a Livewire form, the empty status region no longer doubles the gap under the settings heading, the WireKit panel shows an empty state when nothing is published, and both banners changed their wording (the info-only link names the document, the deemed-consent banner repeats the objection warning with its date). A view you published into resources/views/vendor/legal-consent keeps the previous markup until you publish it again with php artisan vendor:publish --tag=legal-consent-views --force or --tag=legal-consent-wirekit --force.

0.26.1 → 0.26.2

Nothing is required of you, and one behavior changes on purpose. No configuration key and no table changed. Subjects browsing in a language your legal texts are not translated into now meet the consent gate, and on PostgreSQL the ledger relation answers eager loads and existence queries for integer and uuid subject keys.

The consent gate resolves an untranslated mandatory document through the locale chain

The gate read the documents a subject owes in the request locale and nowhere else. An application with terms in de and en let everybody using it in Italian through: Consent::outstanding() answered empty and EnsureLegalConsent passed the request. A mandatory document with no version in the request locale now resolves through the chain registration already walks, fallback_locale and then default_locale, and the gate shows it in the language it was published in.

Expect subjects in untranslated locales to meet the gate after updating. They are the ones who were being let through. A subject browsing in a translated locale sees no change, and a voluntary consent is never resolved this way, because it is never owed.

with('legalConsents') and whereHas('legalConsents') work on PostgreSQL

On PostgreSQL, with a subject keyed by an integer or a native uuid, both ended in SQLSTATE[42883], and so did whereDoesntHave() and withCount(). They work now. Nothing changes on MySQL or SQLite, and legalConsents() still returns a MorphMany.

0.26.0 → 0.26.1

Run php artisan migrate after updating, and that is the whole of it. No code, no configuration key and no table changed. One migration alters six existing trigger functions, and only on PostgreSQL.

The trigger functions this package installs now carry a fixed search_path

A function without one resolves its names through the search path of whoever calls it. All six run as SECURITY INVOKER, so the exposure was small — but SQLens reports every one of them (PGLS.functionSearchPathMutable, severity medium) in every consumer's audit, and with the release audit now sitting in front of a release, in every report before every release.

The migration pins them with SET search_path FROM CURRENT: it stores the search path the migration itself runs under, which is the one the functions were created in and resolve their names through today. An install whose tables live in another schema keeps resolving them there, and legal_documents_guard_delete — the one body that names a table — still finds legal_consents. A literal public would have broken the first case, and an empty path the second.

On MySQL and SQLite the migration does nothing, because these six are PostgreSQL trigger functions. A function that a consumer dropped on purpose is skipped rather than failing the migration chain over it. php artisan migrate:rollback restores the previous state with RESET search_path.

0.25.3 → 0.26.0

Nothing is required of you unless you use the WireKit view variant AND your WireKit is older than 2.47.0. No code, schema or configuration key changed, and nothing throws.

The WireKit view variant now needs pushery/wirekit 2.47.0

It needed 2.26.0. The package has always chosen between two view sets on exactly this floor — the WireKit-native views when a new enough WireKit is installed, the plain views otherwise — so a WireKit below the new floor does not error: you get the plain views, which are the same screens without the WireKit theming.

How to tell whether this affects you: if legal-consent.ui.variant is plain, nothing changes. If it is wirekit or the default auto and composer show pushery/wirekit reports 2.26.0 to 2.46.x, you are on the plain views until you raise your own constraint to ^2.47.0.

Why the floor moved. The WireKit stubs used to set their busy state by hand, writing aria-busy through the attribute bag, because the component could not: loading-target alone rendered nothing, and the component's own busy state was disabled — which blurs the control the subject just activated and drops focus to <body> for the whole in-flight window. WireKit 2.47.0 shipped both halves, so the stubs now pass loading-target and :disable-on-loading="false" and the component carries the wait. What a subject experiences is unchanged; what changed is that one component promises it instead of seven copies of an attribute pair.

The floor is deliberately not a hard require: raising it would force every consumer to a WireKit they may not want. It gates the automatic view choice, which is what it has always done.

0.25.2 → 0.25.3

Nothing is required of you. No code, schema or configuration key changed, and nothing new can stop a composer update.

The manifest now names the PHP extensions the code already used

ext-ctype, ext-filter, ext-hash and ext-mbstring are now declared in require. The shipped code calls into all four, and until this release only laravel/framework, which this package requires, named them.

What this changes for an install: nothing. Composer already refused a PHP built without one of the four, through the requirements of laravel/framework itself. The declaration makes this package's own needs readable from its own manifest, so they stay declared whatever the framework requires next.

0.25.1 → 0.25.2

Nothing is required of you, and there is no migration. The release changes documentation only: no code, no schema, no configuration key. It is written up because a missing section and a free upgrade look identical from the outside, and only the section tells them apart.

The notice-modes documentation now states which combinations the publisher refuses

Notice modes gained the admissibility matrix — which NoticeMode each DocumentType admits, and the three combinations the publisher rejects outright. The rules did not change; they were previously discoverable only by hitting the refusal, at which point the mode is already picked and the text already written. A contract test holds the table to the two predicates in the source, so the page cannot drift from what is enforced.

0.25.0 → 0.25.1

Nothing is required of you, and there is no migration. This release fixes a defect that made a release of a legal text fail, so what you may have to undo is a workaround you put in place against it.

Releasing a legal text no longer deadlocks against its own lock

On 0.25.0, Consent::release() — and the legal-consent:publish path that goes through it — took the activation lock and then took the same lock again for each locale, inside its own transaction. A Laravel lock is not reentrant, so the inner attempt waited five seconds and threw LockTimeoutException. The release failed mid-transaction, wrote nothing, and left the text unpublished.

It only showed on a cache store whose locks are real. The guard in front of both lock sites excludes array and null, which serialize nothing across processes, so on those stores neither side locked and a release completed normally. If you set legal-consent.cache.store (or CACHE_STORE) to redis, memcached, database or file — which this package recommends for production — every release hit it.

If you worked around this by holding a document back from your publish step, or by pinning to 0.24.0, that is what you can now undo. A first publication of a newly registered document is the case that hit it hardest, because a document that already has an active version is usually skipped before the lock is ever taken.

0.24.0 → 0.25.0

Nothing here is required. Both entries are additive and opt-in: an application that changes nothing keeps exactly the behavior it has today. They are written down because each replaces a workaround a consuming application was carrying, and the workaround is what you delete.

The bundled consent checkboxes can bind to a Livewire property

The shipped checkbox stubs restore their state with old(), which is right for a POST form and does nothing on a Livewire screen — old() is empty across a commit, so the component never learns the box was ticked. If that is why you kept your own copy of the markup, pass bind instead:

@include('legal-consent::consent-checkboxes', [
    'documents' => $documents,
    'bind' => 'accept',
])
/** @var array<string, bool> */
public array $accept = [];

Each control then carries wire:model="{bind}.{field}" and no checked attribute beside it. Three things to know before you switch:

  • The array is keyed by FIELD, not by document key. For a document that is legal_terms; the Art. 8 age attestation is named by its bare key. field is what the id, the name, the error bag and RegistrationRules already use.
  • The never-pre-checked guarantee becomes yours. Without bind the stub can promise that nothing but the visitor's own previous submit ticks a box. Under a binding the stub contributes nothing, so the bound value decides — initialize it to false. A pre-ticked box is not consent (CJEU C-673/17).
  • The opt-in accept-time hash goes inert. Livewire submits no form, so the hidden fingerprint input is never sent. Carry contentHash in your own state and hand it to the recorder yourself, or leave it out and keep the prior no-guard path.

One consent route can answer the question the gate is actually asking

Only relevant if you have legal-consent.gate.first_use turned on.

The middleware holds a subject on the union of what changed and what was never accepted. ReConsentForm answered one of the two, chosen by the method you mounted it with — so a single consent route, which is the shape routes.consent_name describes, sent every first-use subject to a form rendering zero documents while the gate kept holding the next request. If you wrote a branch in front of the mount to pick the method, this is the thing it was working around:

<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 implies — first_use_gate for one never accepted, re_consent_gate for one that changed since — inside the same submit. A document in both sets is recorded as a re-consent, the stricter of the two statements.

Keep an explicit :method if you mount two separate routes and want each to stay one question. A mount that names a method is unchanged in every respect.

0.23.0 → 0.24.0

Run migration 000027 — the retention sweep gets its own index

Nothing is required of you beyond migrate. It adds (accepted_at, id) on legal_consents and (sent_at, id) on legal_notices, which is what legal-consent:prune filters on. No data changes and no behavior changes; it is named here only because migrate will report it, and an unexplained migration is the kind of thing an operator stops for.

Worth knowing if your ledger is large: the run that deletes nothing — the normal case for a scheduled sweep — used to be a full scan of the table, and that was the entire cost of the run. Measured on PostgreSQL 18 over 50 000 rows: 1 471 shared buffers and 4.684 ms before, 11 buffers and 0.029 ms after, as an index-only scan.

ignoreMigrations() no longer switches off the three scheduled sweeps

If you published the package's migrations and then called ignoreMigrations() — the path the property's own docblock offers — this release turns three commands back on for you: legal-consent:dispatch-notices, legal-consent:close-objection-windows and legal-consent:prune.

They were registered behind that flag, and the flag carries two meanings. Its docblock offers it for "publish and manage them in the host app instead" — the tables then exist. UPGRADE.md for 0.16.1 read it as declining the tables altogether. The schedule believed the second reading, so following the documented publish path silently lost the change notices owed under § 308 Nr. 5 lit. b, the closing of every objection window (silence never bound), and the retention sweep under Art. 5(1)(e). No error, no warning, tables present.

The registration now asks whether the tables are there, which is the fact the commands depend on. Nothing changes for an installation that genuinely has no tables: nothing registers, exactly as before. ignoreMigrations() keeps its documented meaning and no new configuration appears.

If you turned the schedule.* flags off to silence nightly failures, look at them again — they are the switch for whether you want each sweep, and they are the only switch now.

One active version is enforced by the database on MySQL and SQLite too

Run the new migration (000025). It adds the guarantee PostgreSQL has had since 000001: on SQLite the identical partial unique index, on MySQL a stored generated column plus a plain unique index over it, which is the standard equivalent because MySQL has no partial index.

Before you migrate, check that you do not already hold two active versions of the same document. On MySQL and SQLite nothing prevented it until now, so an installation that lost a lock race at some point may be carrying one — and the migration will refuse to create the index while it is there. select key, locale, tenant_id, count(*) from legal_documents where is_active = 1 group by 1,2,3 having count(*) > 1 names them; retire all but the version that should be in force.

Run the second migration (000026) as well. It appends tenant_id to the affected-subject index the notice sweep walks. Nothing about your data changes and nothing is required of you — it is named here only because migrate will report two migrations rather than one, and a second unexplained migration is the kind of thing an operator stops for.

Nothing else changes. The activation lock behaves exactly as before, including its warning when the configured cache store cannot serialize. It simply is no longer the only thing holding the invariant.

Run migration 000028 — locale and version get the widths their formats need

Run it; nothing else is required of you. legal_documents.locale was varchar(10) and version was varchar(20), and both are narrower than values that are perfectly well-formed: ca-ES-valencia is a valid BCP 47 tag at fourteen characters, 1.0.0-alpha.1+build.123 valid SemVer at twenty-three. They become 35 and 64 on MySQL and PostgreSQL.

Where the old width bites: zh-Hant-TW, zh-Hans-CN and sr-Latn-RS are each exactly ten characters, so a document in one of those locales sat on the limit and any variant subtag went over it. The failure is also invisible where people develop — SQLite ignores a varchar length completely, so the value round-trips at full length there, while MySQL 8.4 in strict mode refuses the row with 1406 Data too long.

On MySQL, look at the size of legal_documents first. Changing a column type copies the table, so the run is proportional to the row count rather than instant. PostgreSQL widens in place. SQLite is skipped deliberately: there is nothing to widen, and an ALTER there would rebuild the table and drop the proof triggers with it.

A 0.x legal document is now enforced

If you publish a legal document at a 0.x version, this release starts gating on it. Until now it gated nobody: the held-major fold uses 0 as its sentinel for holds nothing, so held >= major_version was 0 >= 0 — true for every subject, including one with an empty ledger. The settings screen told those people they held a contract they had never seen, and never offered it to them, because outstanding is the mirror of the same comparison.

Who is affected: only installations that published a document whose major_version is 0 — that is, any version numbered 0.x.y. LegalDraftWriter::setVersion('terms', '0.9.0') accepts it and RenderPipeline derives majorVersion: 0, so it is reachable through the ordinary path rather than only by hand.

What changes for them: subjects who have not accepted that document are now gated, shown it, and asked. That is the point of the fix, and it is a behavior change rather than a repair of something invisible — people who pass today will be stopped after the upgrade.

If you do not want that, publish the document at 1.0.0 or above before upgrading. There is no configuration switch: a document that gates nobody is not a legal document, and a flag to keep it that way would be a way to ship the defect on purpose.

Withdrawal now works at 0.x too. An ending action dropped the holding to the same 0, so a withdrawn 0.x document went on reading as held. A holding is a presence now, so a withdrawal is visible.

0.22.0 → 0.23.0

Nothing to do. No migration and no configuration key changed in this step, and no public contract changed shape.

Two of that release's fixes are behavior a consuming application can observe — a deleted document stayed enforceable until its cache entry expired, and a retention sweep could hit a driver error while repairing the tamper chain on a large ledger — but both are repairs, so there is nothing to change on your side.

0.21.0 → 0.22.0

Markdown tables now render as tables

RenderPipeline registers CommonMark's TableExtension. Until now a Markdown table in a legal text came out as a paragraph full of pipe characters — the sanitizer had permitted table, thead, tbody, tr, th and td all along, so the allowlist was describing a capability the converter never had.

If a source of yours contains a Markdown table, its rendered HTML changes, and that matters here more than it usually would: the HTML is hashed into the proof row. Nothing already published moves — stored rows keep the bytes and the hash they were frozen with — but legal-consent:check-drift will report the source as drifted, because it now renders to something different than the active version holds. Publishing a new version is the intended answer; the old rows stay valid evidence of what was actually shown.

Only tables. TableExtension alone, not GithubFlavoredMarkdownExtension, so autolinking, strikethrough and task lists stay off — turning three unrelated behaviors on while fixing one is not something to do to a rendering surface whose output is frozen into append-only rows.

blockingLocales() returns a BlockingReason, not an English sentence

LegalDraftSet::blockingLocales() and ChangeItems::blockingLocales() returned finished English prose, which left an application one option for translating a release screen: use the sentence itself as a lookup key. That works until the package rewords one, and then the lookup misses and the screen falls back to English — on a compliance surface, with nothing able to notice.

Both now return array<string, Pushery\LegalConsent\Enums\BlockingReason>, and so does LegalReleaseNotReady::$blocking. Render one with __($reason->label()).

The English sentences are unchanged and still reachable as $reason->value — they back the enum, so LegalReleaseNotReady's message and anything logging it read exactly as before. Only the type moved, which your editor and static analysis point at rather than letting a string shift under you.

If you were using the sentences as translation keys, the six keys now ship with the package in all seven locales; drop your own copies and call label().

DefaultConsentManager's registry argument is nullable, and [] changed meaning

Only relevant if you construct that class yourself — resolving it from the container is unaffected.

The fourth constructor argument went from array $documents = [] to ?array $documents = null. It intersects the published documents with the configured registration registry so the form, the rules and the recorder resolve one set, and the intersection used to be skipped whenever the array was empty — because an empty array was read as "no registry was configured".

That conflated two different answers. ask_at_registration => false on every document produces an empty registry, and it is a real configuration meaning ask nobody at sign-up; it got the whole published set back on the checklist while the rules validated none of it and the recorder wrote none of it.

Pass null for the old "do not filter" behavior. A literal [] now means what it says: nothing is registered for registration, so nothing belongs on the checklist.

A new chain now proves who opened it

Two schema additions arrive with the migration and neither touches an existing row: legal_consents.root_proof, written only on the row that opens a subject's chain, and a small legal_ledger_markers table holding one row.

The hole they close: the chain root was a public constant, so anyone able to INSERT could plant a consent nobody granted — one row for a fresh subject_token pointing at it — and legal-consent:verify-ledger reported the ledger intact. From now on the opening row of a chain carries an HMAC only a holder of tamper_evidence_key can produce.

If you do not set tamper_evidence_key, nothing changes. Without a secret there is nothing to prove with, and the column stays null.

Chains you already have are exempt, by design. The marker records the highest legal_consents.id at the moment the feature starts being used; below it, a missing proof is history and cannot be anything else. Requiring one there would turn every existing ledger red on upgrade.

You may set the secret before or after migrating. If it is already configured, the migration stamps the marker. If you set it later, the first write that opens a chain stamps it instead. There is nothing to run by hand either way.

One new failure to recognize. If verify-ledger reports chain-root boundary marker … proof does not verify on a database nobody has touched, this environment is holding a different tamper_evidence_key than the one the ledger was written with. That is a deployment problem, not an incident — check the secret before treating it as one. A keyed run that fails now says this in its output.

0.20.0 → 0.21.0

The session-backed writes are rate-limited by default

The Livewire components' grant, withdraw, object and terminate actions and the web withdraw route append to the same append-only ledger the JSON API does, and were the only ways in with no limit in front of them. The new config key routes.web_throttle defaults to '60,1' — sixty writes a minute per authenticated subject, on ONE budget however the surface is reached — and is applied by the package itself, through the same throttle middleware your routes use (Redis-backed where you called throttleWithRedis()). Past the limit the answer is 429 and no row is written.

A config published under an earlier version already declares the routes block, and the shallow merge never delivers a new key into it — so the package applies the inline default and the behavior changes for you either way, exactly as routes.api_throttle did. Add 'web_throttle' => '120,1' to your own routes block for a different limit, the name of a limiter you registered with RateLimiter::for(), or null to switch it off.

0.19.0 → 0.20.0

ConsentManager gains firstAcceptance()

If you implement Pushery\LegalConsent\Contracts\ConsentManager yourself, it no longer satisfies the interface until you add:

public function firstAcceptance(\Illuminate\Database\Eloquent\Model $subject, ?string $locale = null): \Illuminate\Support\Collection;

Nothing changes for an application using the bundled manager, the facade, or ConsentFake — all three already have it. If you stubbed the interface in your own test suite rather than using ConsentFake, that stub is what breaks: the class fatals at load with "contains 1 abstract method and must therefore be declared abstract". Extending DefaultConsentManager or switching the stub to ConsentFake are both one-line fixes, and the second one survives the next method too.

legal-consent:doctor exits 1 if you have published the config

This is the one change an installation meets without opting into anything, so it is worth knowing before your pipeline tells you.

gate.first_use is a new key inside the existing gate block. Laravel merges a published config flat — array_merge over the top-level keys of legal-consent, no recursion — so your published gate block wins whole and the package default is not applied. The key reads as null, which leaves the gating off; that direction is safe and nothing about your application changes.

What does change is the report. doctor lists a package key that never reaches your runtime as lost, and a lost key is a failure, not a warning:

These keys exist in the package but NEVER reach your runtime config:
  - gate.first_use  (package default: false)

Add the key to your published config/legal-consent.php and the report is clean again:

'gate' => [
    // …
    'first_use' => false,
],

If you have not published the config, there is nothing to do — the package default applies and doctor is unchanged.

A form already mounted as a first-use gate now lists documents

ConsentMethod::FirstUseGate has existed since 0.15.0 and the documentation named this exact tag, but the bundled form sourced from Consent::outstanding(), which filters on the notice mode of a version change. A first acceptance is not a change, so wherever a document had been published silently the screen rendered nothing at all.

If you already mount the form that way, it starts working at this upgrade. That is the fix, and it is also a change in what your users see: people who were waved through are now shown a screen and asked. Expect the acceptances you were never collecting to start arriving — which is the point, but it is not nothing on a Monday morning.

The set it shows is the mandatory documents the subject does not hold, which is wider than "never accepted": somebody who withdrew, declined or terminated holds nothing either and is asked again. Voluntary consents are never in it (Art. 7(4)), and informational pages never are.

Check the spelling of your mount. A Blade template compiles into a file with no namespace, so a bare ConsentMethod::FirstUseGate in the attribute throws Class "ConsentMethod" not found. Our own documentation carried the short form in four places until this release. The form that works:

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

gate.first_use is opt-in, and it needs the screen

Setting legal-consent.gate.first_use to true makes EnsureLegalConsent also stop a subject who owes a first acceptance. It is read strictly — anything but a literal true leaves it off, because a gate that switched itself on for a truthy value would stop every subject of an application that never asked for it.

Turn it on together with the screen above. Without one it is a dead end rather than a loop: the consent route is allowlisted, so the subject lands there and is told nothing is due. doctor reports mandatory documents published while the gating is off, and stays quiet once it is on — that is a decision, and it leaves decisions alone.

Doing nothing is a complete answer. The default is false, and an application whose sign-up records consent on the registration form does not need it.

0.18.0 → 0.19.0

The change-notice proof is now written when the notice is DELIVERED

ChangeNotification implements ShouldQueue, so Notification::send() enqueued and returned — and legal-consent:dispatch-notices then wrote the legal_notices proof row, stamped notified_at on the version and exited 0. With a dead worker, a poisoned job or a refusing mail transport that produced a green hourly run plus an append-only row certifying a notice nobody received. legal-consent:close-objection-windows reads exactly those rows and binds each subject to the contract change by silence, so the false proof turned into a deemed acceptance.

The proof now comes from the delivery side. A listener on Illuminate\Notifications\Events\NotificationSent (channel mail) writes the row; a listener on NotificationFailed clears the version's notified_at so the notice is owed again instead of sitting stamped.

What you have to do: nothing, if your queue works. The observable result of a working run is unchanged — the same rows, for the same subjects, with the same content hash.

What changes if it does not:

  • The command now says Queued N notice(s) for delivery, not Dispatched. It reports what it did.
  • A version whose delivery fails becomes due again on the next sweep, by itself.
  • A queue that discards silently — queue.default=null, or a worker that never starts — fires neither event. notified_at stays stamped and the version is not re-swept on its own; use legal-consent:renotify as documented. What is different is that there is no longer a false proof row, so close-objection-windows refuses loudly (exit 1, the objection window stays open) instead of binding the subject by silence.
  • If you test against this package with Notification::fake(), a faked notifier never delivers, so no proof row is written. Pin the channel instead — config(['legal-consent.notifications.channels' => ['mail']]) — and assert on NotificationSent events. The mail transport in a test environment is the array driver, so nothing leaves the process.

Tamper-evidence now covers tenant_id

LedgerHashChain folds tenant_id into a row's canonical form. Until now it did not, although ConsentGate filters on that column under multi-tenancy — so an acceptance could be moved from one tenant to another with prev_record_hash untouched, and legal-consent:verify-ledger reported the chain intact, with or without an HMAC key.

Single-tenant installations: nothing to do. The field is appended only when it names a tenant, and tenant_id is NOT NULL DEFAULT '' — so every row of an installation that never enabled legal-consent.tenancy produces the same canonical bytes it produced before, and every stored link keeps verifying.

Multi-tenant installations with tamper_evidence already on must re-chain. Every prev_record_hash written before this version was computed without the tenant, so legal-consent:verify-ledger reports every chained row as broken from the first run after the upgrade. Decide before upgrading:

  • re-chain the ledger — LedgerChainRepair::relink() per subject_token, in id order, delete and insert inside one transaction (the shape LedgerSubjectEraser uses) — and record that you did it and why; or
  • accept the discontinuity, note the upgrade date, and read every break at or before it as the upgrade rather than as tampering.

Rows written after the upgrade chain and verify normally either way.

Tamper-evidence is bound to the database engine and the connection time zone

This is not new behavior — it has always been true and was not written down. The chain hashes accepted_at as the string the driver returns. PostgreSQL renders a timestamptz with an offset while MySQL and SQLite render none, and on PostgreSQL and MySQL that rendering follows the session time zone, which Laravel sets from database.connections.*.timezone when that key exists. Without the key the server decides.

So once the first row is chained, each of these invalidates every stored prev_record_hash at once:

  • restoring a dump onto a different engine;
  • adding, changing or removing that timezone key;
  • without the key, a change of the server's time zone setting, a database or role default, or PGTZ in the environment of the process that connects.

legal-consent:verify-ledger then reports the whole ledger as tampered although nothing was touched. Treat both as a re-chain event, exactly like tamper_evidence_key: fix the engine and the connection time zone before the first chained row. The chain deliberately does not normalize the instant — normalizing would change the canonical form of every row already written, on every engine, and the only route back to a verifiable ledger is to rewrite every proof row, which is the one operation the chain exists to make conspicuous.

The ledger refuses an action its document type cannot carry

Consent::record() took an arbitrary action from an arbitrary caller and wrote it. The append-only ledger therefore accepted an objection against a consent, a consent given by silence, a granted on a privacy notice, and a double-opt-in confirmation with no request before it — rows asserting a state the law has no shape for, in a table that cannot be corrected afterwards.

A compatibility matrix now sits at the write choke point and throws Pushery\LegalConsent\Exceptions\IncompatibleConsentActionException. The named transitions (accept(), withdraw(), object(), terminate(), confirm()) are unaffected — they already asked their own half of the question.

Check any direct record() call. Contract terms and privacy notices are Acknowledged (or ReAccepted after a material change); Granted, Withdrawn, Declined, OptInRequested and Confirmed belong to documents with requires_explicit_optin. A confirmation additionally needs a preceding OptInRequested row for the same subject and key.

A document a consent points at can no longer be deleted

legal_documents had no delete protection, so the text a subject agreed to could be removed while the consent row kept pointing at it — against the promise the README makes in as many words. A BEFORE DELETE trigger (migration 0001_01_01_000021) and a model hook now refuse it and throw LegalDocumentInEvidenceException.

Retire a version with is_active = false, which is the supported route and always was. Deleting a version nobody ever accepted still works.

Withdrawal follows from the same change: withdraw(), object() and terminate() fall back to the version the subject actually accepted when no active version exists, so retiring a document no longer makes Art. 7(3) unreachable.

legal-consent:publish refuses an inverted notice timeline

published_at < announce_from < enforce_from was enforced nowhere, so a hard-gating change could take effect before its own announcement and freeze notice_period_days at a negative number. An unconditional guard now throws NoticeTimelineInvertedException, and the period is floored at zero. An --enforce-at in the past with no --announce-at is still the documented immediate gate.

The JSON API is rate-limited by default

The shipped default middleware carried no throttle, so four unauthenticated write endpoints pointed at an append-only ledger with nothing in front of them. The new config key routes.api_throttle defaults to '60,1' and is applied in the package's route file, ahead of your configured chain.

A config published under 0.17 or 0.18 already declares the routes block, and the shallow merge never delivers a new key into it — so the package applies the inline default and the behavior changes for you either way. Add 'api_throttle' => '120,1' to your own routes block for a different limit, or null to switch it off.

Smaller behavior changes

vendor:publish --tag=legal-consent no longer copies the two opt-in migrations along with the required ones. Publish those with their own tags when you want them.
legal-consent:dispatch-notices exits non-zero when a notice went out without its § 308 Nr. 5 lit. b mandatory content, and its --dry-run exits non-zero when the size brake would hold a version back.
legal-consent:close-objection-windows leaves the window open when it could not deem every subject for lack of proof, instead of stamping it closed and reporting success.
all three sweeps implement Isolatable, so --isolated works, and survive a fresh install whose migrations have not run yet.
ConsentSettings and ReConsentForm $locale and $status are #[Locked]. If you set them from a parent component, pass them as mount parameters.
LegalTextManager::releaseAll() answers 404 for a key the manager does not list, where it previously reached the source factory and threw.
the settings screen no longer lists informational documents as outstanding consents.
notice_periods.dcd_termination_days removed from the published config: it is the § 327r Abs. 3 free-termination window, fixed by statute, and nothing read it.
new key legal-consent::ui.not_withdrawable in all seven bundled locales.
withdraw route returns only to your own origin; a foreign Referer lands on routes.home.
new trait method hasAcceptedCurrentLegalMany(array $keys) answers the hasAcceptedCurrentLegal() question for several keys in one read.

MySQL: the identity columns of legal_documents get a binary collation

Migration 0001_01_01_000022 gives key, locale, tenant_id and version a binary collation on MySQL. The unique index over them decides whether two rows are the same document, and MySQL's default collation is case- and accent-insensitive while PostgreSQL and SQLite are not — so terms and Terms were one document on one engine and two on another. Nothing to do; existing rows are unaffected unless you already relied on that insensitivity, in which case the migration fails loudly on the duplicate rather than silently picking one.

0.17.0 → 0.18.0

subject_id widens from an integer to a 64-character string

Both proof tables (legal_consents, legal_notices) held the subject's own primary key in an unsignedBigInteger, which locked out any application whose users carry UUID or ULID keys. The column is now string(64), and migration 0001_01_01_000018 widens an existing installation in place.

For almost every application this is nothing to do. Run php artisan migrate. No stored row is rewritten, and no tamper-evidence proof needs re-chaining: the canonical proof form has always hashed the string cast of every field — so an id that was a bigint and comes back as text hashes identically. Both engine suites assert exactly that, against rows written before the type moved.

Three things worth knowing before you run it:

  • The migration rewrites two tables. On a large ledger that is an ALTER TABLE of real duration; on PostgreSQL and MySQL the affected indexes are rebuilt with it. Plan it like any other schema migration on a big table rather than as a no-op.
  • The rollback can legitimately fail. Once a subject with a non-numeric key has consented, no integer column can hold that proof, so down() refuses rather than dropping or zeroing it. If you have only ever used integer keys it reverses cleanly.
  • If you compare subject_id yourself, a raw query that relied on numeric comparison (WHERE subject_id > 1000, an ORDER BY you expected to be numeric) now sorts and compares as text. Queries built through Eloquent or the query builder with where('subject_id', $model->getKey()) are unaffected.

ConsentManager gains forget()

If you implement Pushery\LegalConsent\Contracts\ConsentManager yourself, it no longer satisfies the interface until you add:

public function forget(\Illuminate\Database\Eloquent\Model $subject): \Pushery\LegalConsent\Support\SubjectErasure;

Nothing changes for an application using the bundled manager or the facade — and if you are performing Art. 17 erasures with your own code today, this is what replaces it.

Check that hand-rolled version before you delete it. It almost certainly does not re-link the tamper chain, because the need for that is not obvious: the erased columns are inputs to the row hash. If yours does not and you have tamper_evidence on, legal-consent:verify-ledger has been reporting a break for every erasure you have already performed — those breaks are real and this release does not repair them retroactively.

The optional v1 backfill no longer skips non-numeric user ids

If you publish and run 0001_01_01_000004_backfill_v1_legal_acceptances, note that it used to skip any users.id that was not all digits — while the column was an integer, that check was the only thing between a UUID and PHP casting it to 0. It now accepts any value with a lossless string form. If you ran the backfill BEFORE upgrading and your users have UUID keys, it imported nothing; the run is idempotent by a source = 'v1_backfill' marker, so delete those rows (there are none) and run it again after migrating.

legal-consent:publish --only-missing and --dry-run now fail on a missing text

A registered document whose source resolves no text used to be a warning under --only-missing, and the run finished green. Only a source whose empty state means nobody has written it yet is warned about and skipped now — the bundled draft source, or your own source class if it implements Pushery\LegalConsent\Content\AwaitsAuthoring. Everything else counts as provisioned and fails, naming the document and the locale.

A missing Markdown file is the case this separates out: nobody is going to write that one. It is a deployment missing a file, and skipping it leaves the empty legal page behind a green deploy — the state this command exists to prevent.

Two runs change their exit code because of it:

  • php artisan legal-consent:publish --all --only-missing --editorial, the line a deploy script runs, exits non-zero when a registered document has no text and its source does not declare AwaitsAuthoring.
  • --dry-run now answers a textless source exactly as the real run does. It used to count every one of them as a warning and end at 0, so the preview reported green for a run that could not be green.

What to do before you deploy this. Run the preview once against your own registry:

php artisan legal-consent:publish --all --only-missing --dry-run --editorial

Every line marked x … no text is a combination that will now fail. For each one, either author the missing file, take the key out of the documents registry, or — if it really is waiting on an editor — put it behind a source that declares AwaitsAuthoring. legal-consent:doctor names the same gaps at any time and changes nothing.

0.16.1 → 0.17.0

If your app has WireKit installed, your consent screens will look different

legal-consent.ui.variant is new and defaults to auto: with pushery/wirekit ≥ 2.26.0 installed, the package now serves its WireKit-native views instead of the plain stubs. Previously that only happened if you had run vendor:publish --tag=legal-consent-wirekit.

This is the fix for a silent defect — an unstyled view renders, so an application using WireKit everywhere else was serving bare HTML on /settings/consents and on the re-consent gate with nothing going red — but it is still a visible change on a screen you may have styled around.

To keep exactly what you have, pin the plain set:

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

Nothing changes for an application without WireKit, and nothing changes for one that had already published the WireKit tag: a published view is still checked before either set.

If you implement ConsentManager yourself

The interface gains two methods — requestConfirmation() and confirm() — so a custom implementation will not satisfy it until they are added. ConsentFake and the shipped manager already have them.

statusFor()'s array shape also gains a pending_confirmation key. Reading code is unaffected; a custom implementation should fill it, and a static analyzer will say so.

Nothing else changes: the two new ConsentAction cases and the new ConsentMethod case are additive, no migration ships, and no existing row means anything different than it did.

If you sign people in through an external provider

registration.without_form_fields is new. Nothing changes unless you set it: the default warn is exactly what 0.16.0 already did. Set it to refuse and an unevidenced mandatory consent raises UnevidencedConsentException instead of being recorded with a log line.

'registration' => ['without_form_fields' => 'refuse'],

Do that only if your registration form uses the field names the package generates — the check can look for nothing else, so an application with its own naming would start failing registrations that are perfectly correct.

If you published the framework-agnostic settings stub

Its withdraw button used to post to # — withdraw_url had no producer in the package, so the fallback always won. The shipped stub now renders the form only when that key is filled, and fills it from a new opt-in route:

// config/legal-consent.php
'routes' => [
    'web' => true,   // POST /legal/consent/withdraw, behind ['web', 'auth']
],

A stub you published earlier is your file and does not change. If its button still posts to #, either turn the route on and copy the @if (($consent['withdraw_url'] ?? null) !== null) wrapper from the shipped version, or point the action at your own route.

The same applies to the WireKit settings stub, where the defect was sharper: it withdrew with wire:click, which on a page with no Livewire component behind it does nothing whatsoever. If you published that one, take the form and the form="lc-withdraw-form-…" confirm button from the shipped version.

If you published config/legal-consent.php

ui is a new top-level block, so mergeConfigFrom() delivers it in full and you need not do anything. Add it to your published file only if you want to pin the variant.

routes.web, routes.web_prefix and routes.web_middleware are different: they sit inside a block your published file already declares, and the merge is flat — so your routes block wins wholesale and those three never reach runtime. The route simply stays off, which is the default anyway. Add them by hand if you want it. legal-consent:doctor lists exactly this case.

0.16.0 → 0.16.1

Nothing to do. Both changes are fixes, and neither asks anything of you.

If you use ignoreMigrations()

The three scheduled commands — dispatch-notices, close-objection-windows and prune — are no longer registered when you decline the package's tables. Until now they were, and ran nightly against relations that do not exist: a non-zero exit each time, and with schedule monitoring one entry in your error tracker per run.

If you had turned the schedule.* flags off to silence that, you can turn them back on. Nothing registers while the tables are declined, and the decision is made at boot with no database access.

If you published a settings stub

resources/views/consent-settings.blade.php and its WireKit variant now render the document title as a link when legal-consent.document_url is configured, and as plain text when it is not. A stub you published earlier is your file and does not change — copy the @if (($item['url'] ?? null) !== null) block from the shipped version if you want the link.

The stub headers also now list all seven keys the presenter delivers. The framework-agnostic one additionally states what it never did: its withdraw form's withdraw_url is not supplied by the package. As shipped that button submits to #. Point it at your own route.

0.15.0 → 0.16.0

Almost nothing to do. Everything in this release is additive except one refusal, and that one only fires on code that was already producing an unverifiable chain.

The tamper-evidence chain now refuses a row it cannot hash

LedgerHashChain::hashRow() accepts any object, and it used to fold anything without a string form — an array, an object, a bool false — onto the same S0: an actual empty string produces. Four different rows therefore shared one hash, and the class docblock promised the opposite.

Such a value now raises UnhashableProofFieldException, naming the field and the type.

You are affected only if you hand it something that is not a database row. Both shipped call sites pass raw DB::table(...) rows, so the package's own paths are unchanged, and no existing hash moves: null, string, int and float encode byte-for-byte as before.

The realistic case is an override. DefaultConsentManager::latestChainedRow() is protected, and returning an Eloquent model from it is the obvious rewrite — but LegalConsent casts document_type, action, method and accepted_at to enums and a date object, so four of the eighteen proof fields hashed as empty, including which document, which act and when. That wrote a link legal-consent:verify-ledger could never reproduce, and the command later reported tampering on rows nobody had touched.

If you have such an override, return the row as the driver gave it to you:

protected function latestChainedRow(string $token): ?object
{
    return DB::table('legal_consents')->where('subject_token', $token)->latest('id')->first();
}

If you ran with a model-returning override before this release, the rows written that way already carry unverifiable links. The ledger is append-only, so they cannot be corrected — run legal-consent:verify-ledger to see which subjects are affected and record the cause alongside your retention notes.

A new log warning, and nothing to change

Recording a mandatory document whose legal_<key> field is absent from the request now logs a warning. It still records. If you see it, the Registered listener is running on a sign-in route that has no registration form — the section above on FirstUseGate is the fix.

0.14.0 → 0.15.0

Nothing to do. This section exists because the composer manifest changed visibly, and a dependency change is worth a sentence even when it asks nothing of you.

The manifest now requires laravel/framework

It used to name twelve illuminate/* split packages. laravel/framework replaces every one of them at the same version, so the resolved dependency graph is identical and no lock file moves.

The old manifest promised an install without the framework and did not keep it: shipped code calls at least a dozen helpers that only Illuminate\Foundation\helpers.php defines, config(), app(), trans(), view() and request() among them, at well over a hundred call sites, and no split package provides a single one of them. Such an install resolved cleanly and then fatalled at the first of those calls. Nobody saw it because orchestra/testbench pulls the whole framework into the vendor tree.

If your application is a Laravel application, you already have the framework and nothing changes.

If you sign people in without a registration form

New in this release: ConsentMethod::FirstUseGate, for the interstitial after authentication and before first use. Two things are worth doing together:

First, capture the acceptance where it actually happens — in the interstitial template:

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

Then turn off the Registered listener, which assumes a form validated the tick:

// config/legal-consent.php
'registration' => ['listen_to_registered_event' => false],

Leaving the listener on without a registration form writes an acceptance row for every mandatory document on the first provider callback, without a human having done anything.

0.13.0 → 0.14.0

⚠️ MariaDB is no longer supported

0.13.0 supported MariaDB. This release withdraws that, and the withdrawal is the breaking change in it. If you installed 0.13.0 on MariaDB, read this section before upgrading.

The support should not have shipped. This package proves itself against the engines it targets — SQLite, PostgreSQL and MySQL 8.4 LTS, the set Laravel Cloud runs — by re-running its whole database suite against real servers. MariaDB was never in that set. It entered 0.13.0 as the repair of a side effect rather than as a decision: 0.10.0's proof-column guard named the drivers it could protect, MariaDB was not among them because Laravel carries mariadb as its own driver name, and the fix chosen was to support the engine instead of to keep refusing it.

What changes. ProofColumnGuard::assertSupportedEngine() refuses the mariadb driver again, so a fresh install stops at the proof-column migration with a named exception instead of completing. The engine-specific trigger arms — the proof columns, the append-only triggers on legal_consents and legal_notices, and the change-set freeze guard — no longer write MariaDB bodies.

If you are running 0.13.0 on MariaDB, your database already carries those triggers and nothing in this release removes them. Your options:

  • move legal_documents and the ledger to PostgreSQL, MySQL 8.4 or SQLite — the engines whose trigger this package writes and tests against real servers. This is the supported path;
  • stay on 0.13.0, and understand that MariaDB is no longer tested: a future defect in those triggers there would not be caught;
  • do not expect a rollback to work cleanly. ProofColumnGuard::drop() and the migrations' drop paths still name mariadb on purpose, so the triggers 0.13.0 installed can be taken off — but the install paths are gone, and re-running the migrations forward will refuse.

Pointing the mysql driver at MariaDB is no way around this. MariaDB reports a version such as 11.4.4-MariaDB, which clears the MySQL 8.4 floor by its number alone, but it is not MySQL, and the package is no more tested against it under that driver than under mariadb.

0.12.0 → 0.13.0

Your change notices now render in the package's own template

The one behavior change in this release that is on by default. Until now a change notice went out inside Laravel's global notification template — greeted with "Hello!", closed with "Regards," and explaining what to do "if you're having trouble clicking", all resolved from your application's translations. A German § 126b declaration therefore arrived wrapped in English whenever your app's locale differed from the document's.

Nothing you have to do. If you had branded that template and want it back:

// config/legal-consent.php
'notice_mail' => ['view' => null],

Your proof rows are unaffected, and that is measured rather than assumed. legal_notices.notice_body is assembled from the mail's own line collections; the shell sets the template and never touches them, so the body and its hash are byte-for-byte what they were. Only one thing ever adds to that body, and only once you ask for it — see below.

Name the declaring person (§ 126b BGB)

Optional, and worth doing. The package claims the durable medium and named nobody:

// config/legal-consent.php
'notice_mail' => [
    'identity' => [
        'declarant' => 'Beispiel GmbH',
        'postal_address' => 'Musterweg 1, 10115 Berlin',
    ],
],

That line is appended to the notice and therefore to the proof row, which is the point of putting it there rather than in the template. Notices written before you set it are untouched; notices written after carry it, so their hashes differ from earlier ones — which is correct, and worth knowing before you compare two rows and wonder.

Multi-tenant applications should not use this block. Bind ResolvesNoticeIdentity instead: a single global declarant names the wrong legal person in every tenant but one, and a wrong declarant is worse than an absent one.

The three notifications are no longer final

ReconsentRequired, LegalChangeInformational and DeemedConsentNotice now extend a shared ChangeNotification. If you were copying one to change its wording, subclass it instead and point notice_mail.notification.{mode} at yours. Nothing about the existing classes changed for a caller that does not.

An info-only change under a regulated regime can now be refused at publish

If you publish --info with --regime=p2b, --regime=eecc or --regime=gdpr, the advance-notice period for that regime is now enforced — previously an info-only change had no such check at all. A P2B change with less than 15 days' standstill will now be rejected where it used to publish silently, which is the point: Art. 3(3) makes a change implemented that way void, so a publish that succeeded was the worse outcome.

Declaring a regime on an --editorial change is refused too. An editorial change owes no notice, so its regime's period applied to nothing.

The example impressum key is now imprint

Only the example in the shipped config/legal-consent.php changed. The registry is yours: if you published the config — which the install instructions tell you to do — nothing moved, because documents comes from your file.

It matters for one group: anyone who never published the config and relied on the shipped example, whose text therefore lives at resources/legal/impressum/. Either rename that directory to imprint, or publish the config and keep your own key. Both are one step, and the second is the one to prefer anyway.

The rename fixes an inconsistency inside the package rather than a preference: every other key in the catalog is English, lang/*/titles.php already headed this page imprint, and a consumer whose directory is called imprint — as the starter kit's is — had to write a permanent exemption into any check that compares their config against the package default, with "it is called something else here" as the reason. An exemption whose reason is a naming collision reads like backlog forever.

legal-consent:doctor no longer fails on a list you deliberately keep shorter

If you pinned the doctor out of a CI step because it kept reporting locales.1, put it back. That finding was wrong — see the changelog. Nothing you configured needs to change.

0.11.0 → 0.12.0

Act on this FIRST: your info-only and deemed-consent changes are about to reach people

Read this before you deploy, not after. Until now, an info-only (--info) or deemed-consent (--deemed) change published as a minor or patch bump — the shape this package's own documentation told you to publish — selected no subjects at all. The sweep reported success, wrote no proof row, and stamped its watermark anyway. Nothing surfaced it.

The audience is now mode-dependent: a gating change still goes only to the subjects its middleware will block, and a non-gating change goes to every current party, because that is who the notice duty attaches to.

For a large installation this is a fan-out you have never seen. If a hundred thousand people hold your terms, the first sweep after this upgrade mails a hundred thousand people. Look at the number before it leaves the queue:

php artisan legal-consent:dispatch-notices --dry-run

It reports the audience of every due version, sends nothing, and stamps nothing. A normal run now names the same per-version count before it sends, so the number is never invisible after the fact.

What is NOT at risk: changes that were already swept. Their notified_at is stamped and the sweep never revisits them, so nothing in your history goes out again on its own — reaching that cohort takes the explicit legal-consent:renotify below. The automatic case is narrower: a change that is due and not yet dispatched at the moment you upgrade.

If you want a hard brake for that first run, set a ceiling before you deploy:

// config/legal-consent.php
'notifications' => [
    'max_recipients_per_run' => 500,
],

A version above it is held back without being stamped — nothing is lost, and the same notice is still owed on the next run. Release it with --force once you have looked, or raise the limit.

It ships as null, and that default is deliberate. A limit that were on by default would withhold a legally required notice from every installation that never asked for one — the exact failure this release removes, and the expensive direction: under P2B Art. 3(3) a change implemented without notice is void, while an oversized send is merely expensive.

The cohort left behind, and how to reach it

Repairing forward does not help versions that were already swept: their notified_at is stamped, so they are never considered again. If you published an info-only or deemed-consent change before this release, its audience was never notified — and under P2B Art. 3(2) a change implemented without notice is void (Art. 3(3)).

php artisan legal-consent:renotify terms de 1.1.0     # clears that version's watermark
php artisan legal-consent:dispatch-notices --dry-run  # see the audience
php artisan legal-consent:dispatch-notices            # send

Whether to renotify is your call: it is a decision about a legal event that has already happened, and only you know what else you sent. The package will not do it on your behalf.

Deemed consent now binds subjects it silently skipped

legal-consent:close-objection-windows shares that audience, so the § 308 Nr. 5 lit. b fiction was also being recorded for nobody. It now binds every silent party — which is the correct behavior and a real change in what appears in your ledger. A version whose objection window is still open will produce DeemedAccepted rows on the first run after the upgrade.

If you published your own translations

notifications.php gains one key: contract.consequence_undated, used when a version carries no enforcement date. contract.consequence now interpolates :deadline. See the changelog.

Run the migration

php artisan migrate

Two new tables, legal_change_sets and legal_change_items, hold the per-version description of what a change actually changed. They touch nothing that exists: no column is added to legal_documents, no row is rewritten, and a version with no description renders the same notice it rendered before. On PostgreSQL and MySQL the migration also installs the triggers that freeze a published description; on SQLite the model hook does that job alone.

Nothing else is required

The description is opt-in. If you never author one, this release changes nothing about your notices beyond the fixes above. When you are ready, see Saying what changed — and note that change_items.required ships off: turning it on makes a description a precondition of releasing any change that owes a notice, which is a decision about your editorial process, not a default a package should make for you.

Deemed consent now needs durable_medium.proof on

If you use DeemedConsent and have durable_medium.proof switched off, legal-consent:close-objection-windows now refuses to run rather than bind a population by silence against no evidence. § 308 Nr. 5 lit. b BGB makes the delivered warning a validity condition of the fiction, so a DeemedAccepted row without it is a consent record your own proof table contradicts. legal-consent:doctor reports the same contradiction, so you find it before a window closes.

0.10.0 → 0.11.0

Nothing to do unless you publish the WireKit view variants. No migrations ship, no config key changes, and the PHP API is untouched. If you use the plain stubs, your own markup, or no UI at all, you can skip this section entirely.

Act on this: the WireKit views now need pushery/wirekit ≥ 2.26.0

If you ran vendor:publish --tag=legal-consent-wirekit, upgrade WireKit before taking this release:

composer require "pushery/wirekit:^2.26.0"

What the floor buys, and why it is not cosmetic. A handful of strings inside those views are WireKit's own, not this package's — the external-link hint on the full-text link, the sr-only prefix on the admin policy notice, the dismiss label. They run through __() with the English text as the key, and until 2.26.0 WireKit shipped no translation catalog. So on a German consent screen a screen reader announced (opens in new tab) and Notice: while every visible word around them was German.

There is no visible symptom. Nothing on the page looks wrong, no exception is thrown, and the only person who encounters it is the one who cannot see the screen — at the moment they are deciding something legally binding. 2.26.0 ships de and registers it itself, so the correct announcement arrives with no publishing step and no configuration on your side.

Below 2.17.1 the two older breakages from 0.9.0 still apply unchanged: the admin editor's wire:model lands on the wrapper instead of the textarea and everything typed into it is lost on save, and the manager table cannot emit a row header (WCAG 1.3.1).

If your app is not German or English, supply five strings yourself

WireKit's catalog covers en and de. This package bundles seven locales, so in es, fr, it, nl and pt those WireKit strings are still announced in English. Put them in your app's lang/{locale}.json, which is the file Laravel's JSON loader reads:

{
    "(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 loader does not read that directory, so edits there have no effect.

This package deliberately does not ship those for you. They are JSON string keys, which are application-global: defining them here would silently retranslate every other WireKit component in your app, including ones this package never touches. That is not a choice a dependency should make on your behalf.

If you already copied the WireKit stubs into your app

vendor:publish writes the views into your project, so your copies do not change when you upgrade the package. Nothing in this release changes those files — the improvement lives in WireKit itself, so upgrading WireKit is enough and re-publishing is not required.

0.9.0 → 0.10.0

On PostgreSQL, MySQL or SQLite there is nothing to do beyond running the migration, and everything else below is invisible to an application that installs this package into a normal Laravel project.

On MariaDB, SQL Server or any other engine the migration now stops instead of running. If that is you, read The migration now REFUSES an engine it cannot protect below before you run php artisan migrate.

Run the migration

php artisan migrate

It makes legal_documents.ui_wording nullable, so an informational document — a page that is published and binds nobody — can carry no acceptance sentence. It changes no existing row.

On SQLite this migration rebuilds the table, because SQLite cannot alter a column in place. That is handled: the migration re-installs the proof-column trigger afterwards, on both up() and down(). If you have written your OWN triggers on legal_documents, re-create them after upgrading — a rebuild keeps indexes and drops triggers, and nothing warns you.

⚠️ The migration now REFUSES an engine it cannot protect — MariaDB, SQL Server, anything else

This is the only change in this release that can stop php artisan migrate, and it affects installations that upgraded cleanly until now. Up to 0.9.0 a fourth engine migrated green and simply got no proof trigger on legal_documents — the immutability this package builds its evidentiary weight on, silently absent. It now stops instead:

legal_documents cannot be protected on the 'mariadb' driver: the proof-column trigger is
written for PostgreSQL, MySQL and SQLite. Publishing legal texts without it would leave every
frozen row editable, so this stops rather than continuing quietly.

MariaDB is affected even though the trigger syntax would run there. Laravel returns the driver name from your connection configuration verbatim, and it ships mariadb as its own driver — so a MariaDB connection is never seen as mysql. SQL Server (sqlsrv) and any custom driver hit the same refusal.

MariaDB was briefly supported in 0.13.0, and is refused again from 0.14.0. Do not upgrade to 0.13.0 for this reason. See the 0.13.0 → 0.14.0 section at the top of this guide.

The refusal is raised before anything is altered, so a refused migrate leaves your schema exactly as it was; there is no half-applied state to clean up. Your options:

  • move legal_documents to PostgreSQL, MySQL or SQLite — the other engines whose trigger this package writes and tests against real servers;
  • or stay on 0.9.x, and know that your published rows are not protected by a database-level trigger today. The application-layer guard still refuses an edit through the model, but a direct UPDATE is not stopped.

An informational document published under 0.8.0 or 0.9.0 keeps its wrong sentence

informational arrived in 0.8.0. Until this release, publishing one froze the default acceptance sentence into ui_wording — a page that binds nobody, carrying "Ich habe die Bedingungen gelesen und akzeptiere sie." The fix applies to new publishes only, and the migration deliberately backfills nothing: ui_wording is a proof column, immutable after publish by both the model and the database trigger, so nothing may rewrite it in place. Find affected rows:

select * from legal_documents where type = 'informational' and ui_wording is not null;

(select * rather than a column list on purpose: key is a reserved word on MySQL and needs backticks there, while PostgreSQL wants double quotes — and the row you are looking at is worth seeing whole anyway.)

The remedy is to publish a new version of each such document under 0.10.0, which stores no sentence at all. The old version stays in the ledger, which is the point — the history is evidence, and rewriting it is what this package exists to prevent.

ui_wording can now be null, and code that renders it needs to say so

Only an informational document has none. Every other class still fails loudly when no sentence resolves, so nothing that was working stops working — but the types changed:

-Document::$uiWording        // string
-PublishedDocument::$uiWording  // string
+Document::$uiWording        // ?string
+PublishedDocument::$uiWording  // ?string

If your own view renders the wording of an arbitrary document, guard it:

@if ($document->uiWording !== null)
    <p>{{ $document->uiWording }}</p>
@endif

DefaultConsentManager::acceptanceFingerprint() now throws for an informational document rather than hashing an empty sentence. Nothing accepts such a page, so nothing needs to fingerprint one.

An objection and a termination now refuse the classes they cannot apply to

Consent::object() reaches a contract and a privacy notice; Consent::terminate() reaches only a contract. The other combinations throw NotObjectableException / NotTerminableException instead of appending a ledger row that asserts a state which does not legally exist.

If your app calls either on an arbitrary document key, check the type first — or let the exception surface, which is the honest outcome: the ledger is append-only, so a wrong row cannot be taken back.

The bundled settings component can also switch the two endpoints off entirely, which is worth doing if your product has no answer to them. Removing the buttons from a published view does not: Livewire dispatches to any public method of an embedded component.

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

An unknown document_key is now a 404, not a 500

Three status codes changed, all in the same direction — from "this package broke" to "your request does not name something that exists here":

Request before now
JSON API, a key that is not published 500 404 + error: unknown_document
Livewire component, a key that is not published 500 404
Livewire component, a transition the class cannot carry 500 404

Act on this only if you assert on the old codes. A client that treats 5xx as retryable was retrying a typo against endpoints that append to a ledger; a monitoring rule that alerted on this package's 500s was alerting on its consumers' mistakes. Both stop by themselves — nothing to change unless you have a test or an alert pinned to 500.

The JSON API error body is unchanged in shape and now covers this case too, so a client can tell the two refusals apart:

{ "error": "unknown_document", "message": "No legal document is published under the key 'privacy'.", "document_key": "privacy" }

404 also covers "not published here": a document whose only active version is in a locale you do not serve, and which fallback_locale does not reach, is not being served to that subject.

The Livewire components stay deliberately quieter: the rendered 404 is the framework's plain page and carries nothing of the reason, which names the document key and its legal class — the template author's business and not the subject's.

Be aware of where that reason does NOT go. It rides on the exception, and Laravel never reports a NotFoundHttpException, so it reaches neither your log nor your error page. If you want these visible, report 404s yourself — a custom exception handler, or an APM configured to capture them.

GET /legal/status answers {} instead of [] when nothing is published

The payload is a map keyed by document key. PHP cannot tell an empty map from an empty list, so the empty case used to serialize as a JSON array — which breaks a client that decodes it into a dictionary, on exactly the response that carries no other sign that anything is wrong. It is now always an object.

Act on this if your client branches on the array form (Array.isArray(res), a decoder with an array fallback). The populated shape is unchanged, so a client that only reads keys needs nothing.

The composer manifest now names six more illuminate/* components

illuminate/auth, illuminate/bus, illuminate/cache, illuminate/collections, illuminate/http and illuminate/routing were being imported by shipped code without being declared. Every one of them is already present in any Laravel application, so composer update resolves them from what you have — nothing new is downloaded and no version is forced.

It matters only if you deliberately installed this package without laravel/framework, against a hand-picked set of components. That never worked: the service provider imported classes the manifest did not require, so the container failed on boot. It resolves correctly now.

LegalDraftSaved::dispatch() and LegalDraftReviewed::dispatch() are gone

Both event classes dropped the framework's Dispatchable trait. If you dispatch either event yourself — unusual, since this package dispatches them — replace the static call:

-LegalDraftSaved::dispatch($draft, $actor);
+event(new LegalDraftSaved($draft, $actor));

Listening is unaffected. The class names, the constructor signature and the public properties are unchanged, so every listener, subscriber and Event::fake() assertion keeps working untouched.

If you published the optional v1 backfill migration

Re-publish it to pick up the corrected version:

php artisan vendor:publish --tag=legal-consent-backfill --force

It previously coerced two configuration values and a database id without checking them. On an application whose auth configuration holds something other than a class-name string, that wrote the literal Array into subject_type; and a non-numeric user id was folded onto 0, attaching an imported acceptance to whichever user has that id. Rows it cannot read are now skipped instead. If you already ran the old version, check legal_consents for rows with source = 'v1_backfill' and a subject_type of Array or a subject_id of 0 before trusting the import.

0.8.0 → 0.9.0

0.9.0 carries one thing you must act on, and only if your app publishes the WireKit view variants. No new migrations ship, no config key changes, and the PHP API is untouched.

Act on this: the WireKit views now need pushery/wirekit ≥ 2.17.1

If you ran vendor:publish --tag=legal-consent-wirekit, upgrade WireKit before taking this release:

composer require "pushery/wirekit:^2.17.1"

The views were relying on component capabilities that arrived in 2.17.1. Below it, two things fail without any error:

  • the admin editor's wire:model lands on the component's wrapper instead of its textarea, so everything typed into the editor is lost when you save;
  • the manager table cannot emit a row header, so every status cell loses its programmatic association to the document it belongs to (WCAG 1.3.1).

If you do not use the WireKit views — the plain stubs, your own markup, or no UI at all — nothing here applies. WireKit stays a dev-only dependency of this package; it is never installed into your app on its behalf.

If you already copied the WireKit stubs into your app

vendor:publish writes the views into your project, so your copies do not change when you upgrade the package. Re-publish them to pick up the new editor binding, the declared toolbar, and the row header:

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

--force overwrites. Diff first if you have edited the published copies — the package ships them as a starting point and expects you to.

Nothing else changes

The admin editor's binding and toolbar, and the manager's row header, are internal to those views. No route, class, event, config key or database column moves in this release.

0.7.0 → 0.8.0

0.8.0 adds a fourth document class and carries one breaking change, which only affects code that constructs a package class by hand. No new migrations ship — nothing in your schema changes, and the new class needs no column of its own.

Breaking: LegalDocumentReleaser lost an unused constructor argument

It took a TenantContext it never used. If you resolve the class from the container — which is how the package itself and the documentation use it — nothing changes:

app(LegalDocumentReleaser::class)->release(/* … */);

Only a hand-rolled construction needs the third argument dropped:

// before
new LegalDocumentReleaser($publisher, $resolver, $tenants);
// after
new LegalDocumentReleaser($publisher, $resolver);

New: the informational legal basis

For a page you must publish but which binds nobody — an Impressum (§ 5 DDG), a cookie policy, an accessibility statement. Nothing changes for existing documents; this is purely additive.

// config/legal-consent.php
'imprint' => [
    'source' => 'markdown',
    'legal_basis' => 'informational',
],
php artisan legal-consent:publish imprint de --editorial

Such a page never appears in the registration checklist, never writes a ledger row, never gates access and never sends a notice — and it is the only class that falls back to your default_locale when a translation is missing.

One guard is worth knowing about before you edit the registry: a document whose active version is a contract, privacy notice or consent can no longer be republished as informational. That would silently remove the gate while the recorded acceptances stayed on file, so it is refused. Register the page under a new key instead.

If you have a published config/legal-consent.php, run the new doctor after upgrading — a flat merge means keys added inside a block your file already declares never reach your runtime:

php artisan legal-consent:doctor

0.6.0 → 0.7.0

Nothing to do. This release adds legal-consent:doctor and changes nothing you have to act on: no migrations, no configuration keys, no signature that moved.

0.5.0 → 0.6.0

0.6.0 is a minor bump that carries one breaking change to the tamper-evidence chain format, plus additive, opt-in features. No new migrations ship — nothing in your schema changes.

1. Tamper-evidence chains reset (only if you enabled tamper_evidence)

The canonical serialization that feeds each ledger row's prev_record_hash is now injective: null is distinguished from '', and every field is length-prefixed so an embedded separator can no longer shift field boundaries — closing a hole where two distinct rows could share a hash. This changes every computed chain hash, so a chain written by any earlier version no longer verifies (the previous \x1f format was byte-identical across v0.1.0–v0.5.0, so every prior release is affected, not only v0.5.0).

  • If legal-consent.tamper_evidence was off (the default), there is nothing to do.
  • If it was on with persisted rows, legal-consent:verify-ledger reports a break after upgrading. Treat it as a one-time chain reset. The rows themselves are untouched and remain valid proof — each row's content_hash, ui_wording_snapshot and subject_token are unaffected; only the chain linkage no longer verifies. From 0.6.0 on, newly appended rows chain under the injective format and verify normally; if your compliance process needs a clean verify, archive/export the pre-upgrade ledger as the baseline and start the verified chain from the first 0.6.0 append.
  • Rolling deploy: during a mixed-version window, old (\x1f) and new (S<len>:) writers append interleaved link formats and the verifier surfaces transient breaks. Quiesce the ledger writers (or run appends/sweeps from a single version) across the cutover, and run verify-ledger only once the whole fleet is on 0.6.0.

2. Opt into the registration accept-time guard (optional)

The registration path can now catch a version published between page load and submit — the guard the re-consent form already has. It is opt-in and off by default:

  • Each Consent::registrationChecklist() item exposes contentHash (the render-time fingerprint) and hashField() (legal_{key}_hash). Render that hidden input — the shipped consent-checkboxes stub does it automatically when you feed it a checklist item's ->toArray() — and a mid-form version change throws DocumentChangedException instead of silently freezing a text the registrant never saw.
  • A form that does not render the field behaves exactly as before; no action is required to keep the current behavior.

0.4.0 → 0.5.0

0.5.0 is a minor bump with two changes that alter existing behavior.

Correction (2026-09-05). This paragraph said "No new migrations ship — nothing in your schema changes", and that was wrong: 0.5.0 shipped two migrations, …000013_add_affected_subject_index_to_legal_consents_table and …000014_drop_legal_consents_document_foreign_key_on_sqlite. Anyone following this guide across 0.4.0 → 0.5.0 was told not to migrate. Run php artisan migrate for this step. The sentence is corrected rather than deleted, so that a reader who acted on the old one can see what changed.

On SQLite, migration 000014 rebuilds the legal_consents table. That is how SQLite drops a foreign key — the table is copied, and anything attached to the old one goes with it. The package's own append-only triggers are not affected (they are created on PostgreSQL and MySQL only), but a trigger you wrote yourself on legal_consents is dropped and not restored, and the migration does not run inside a transaction because SQLite's schema grammar is not transactional. Re-create your own triggers after this step.

1. The ledger models are no longer mass-assignable

LegalConsent, LegalNotice and LegalDocument carried $guarded = []. The append-only guard refuses a row's mutation, and a forged proof row is an insert, so anything reaching LegalConsent::create($attributes) could write or backdate one. All three models are fully guarded now, and the package writes through its own curated attribute arrays.

If you wrote these rows yourself, create() and fill() throw MassAssignmentException after the upgrade. Switch those calls to forceCreate() / forceFill() — the deliberate, auditable door — or, better, route them through Consent::record() and its named transitions, which fill the proof columns and the tamper-chain link for you. The break is loud and immediate; nothing fails quietly here.

2. Registration rules, the displayed checklist and the recorded row resolve identically

The three used to answer the same question separately: the validation rules came from the config registry, the checklist read published rows, and the recorder fell back to the default-locale version of a mandatory document. A registration could therefore require a checkbox for a document that was not published, or omit a control whose acceptance was recorded anyway. All three now use the recorder's resolution — configured keys intersected with the active rows, with the default-locale fallback for mandatory documents only (a voluntary consent may never be required, Art. 7(4)).

Two consequences to check against your own install:

  • The consent section is dormant until you publish. An unpublished document demands nothing and renders no control. If your registration form was relying on the config registry alone, publish the documents it asks for before deploying.
  • A document's legal nature is read from the published row, not from its config entry, so a legal_basis that drifted from what was published can no longer decide whether a checkbox is mandatory.

0.3.x → 0.4.0

0.4.0 turns the legal texts from a read-only source into an admin-maintained, provable record: drafts are edited and reviewed, published versions are frozen, and the public page and the consent ledger read the same frozen bytes. Most of the surface below is new; the breaking parts are the removed config keys, the removed source drivers, and the fact that a published row can no longer be edited in place.

1. Run the new migrations

Four new migrations ship, plus one optional cleanup. Publish and migrate:

php artisan vendor:publish --tag=legal-consent-migrations
php artisan migrate
Migration What it does
…_000009_add_document_id_index_to_legal_consents_table Indexes the ledger's document lookups.
…_000010_create_legal_drafts_table The draft store the editor writes to.
…_000011_protect_legal_documents_proof_columns Makes a published legal_documents row immutable (see §4).
…_000012_add_chain_fork_guard_to_legal_consents Unique index (subject_token, prev_record_hash) so a concurrent tamper-chain fork is rejected, not written (only relevant with tamper_evidence on).
optional/…_000003_drop_legal_consent_cache_from_users_table Drops the unused per-user cache columns. Optional — publish it explicitly if you added them.

000011 is deliberately the last migration in the set, so it runs after every backfill of an existing proof column. If you maintain a fork with extra migrations that rewrite legal_documents, read §6 before you add them.

2. Configuration changes

Re-publish the config (or merge these by hand):

php artisan vendor:publish --tag=legal-consent-config --force

Removed

  • sources.database — the database source driver was a no-op and has been deleted. Documents maintained inside the app now live in the draft store; set a document's source to drafts (see below).
  • sources.cms (and its resolver closure) — the CMS-adapter path is removed. The built-in draft store and admin editor replace it. If you resolved legal text from an external CMS, move that content into a draft (author it in the editor, or seed legal_drafts from your CMS) so the published bytes are the bytes the ledger proves.
  • tenancy.column — the tenant column name is now the fixed constant tenant_id (Pushery\LegalConsent\Support\TenantContext::COLUMN). If you set tenancy.column to a custom name, rename that column to tenant_id.

Added

  • sources.drafts — the driver behind the admin-maintained documents. A document opts in with 'source' => 'drafts' in its documents.* entry.
  • admin.ability (default null) — the Gate ability that guards the admin screens. It is required to reach them: with it unset, both screens return 404. There is no opt-out — name an ability and define the Gate. See §5.
  • cache.enforceable_ttl — how long the set of currently-enforceable documents is cached (seconds). The cache is flushed automatically on publish.

3. Removed and renamed classes

If you referenced any of these directly, update your code:

Removed / renamed Replacement
Content\Drivers\DatabaseDriver Content\Drivers\DraftDocumentSource (sources.drafts)
Content\CmsResolver, Content\ClosureCmsResolver, Content\Drivers\CmsAdapterDriver The draft store + admin editor
Content\LegalDocumentManager Content\LegalSourceRenderer
LegalDocumentManager::document(...) LegalSourceRenderer::renderSource(...)

4. Published documents are now immutable

A published legal_documents row is frozen proof — the exact sanitized text a subject was shown and the hash the ledger snapshots. From 0.4.0 a database trigger (on PostgreSQL, MySQL and SQLite) plus a model hook reject any edit to a proof column after publish; only activation state and the sweep timestamps may change. Correcting a legal text is a new version, never an in-place edit.

If your app (or a seeder) updated a published row directly, that write now raises LegalDocumentFrozenException (through the model) or a database error (through raw SQL). Publish a new version instead.

5. The public page reads the frozen row

Render a public legal page from the frozen, published row via the new read path:

$document = Consent::published('terms', app()->getLocale());

// $document?->html is the exact stored bytes; $document?->contentHash the exact
// stored hash — the same text the ledger proves. Returns null before a locale is
// published (render an "in preparation" shell), and never falls back to another
// locale.

Do not render the source on a public page — a source can drift from the published row between an author's edit and the next publish, and a subject would then accept text A while the ledger snapshots hash(text B).

6. The admin screens (optional)

Two Livewire screens ship as publishable stubs — a manager (release per document) and an editor (per document, per locale). They are opt-in and fail-closed:

  1. Name a Gate ability in admin.ability and define it, e.g.

    // config/legal-consent.php
    'admin' => ['ability' => 'manage-legal-texts'],
    
    // a service provider
    Gate::define('manage-legal-texts', fn ($user) => $user->isLegalAdmin());
  2. Route the components behind your own admin middleware, mounting them as Livewire components. With admin.ability unset, or the Gate denied, both screens 404 (they never reveal they exist).

    <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; the manager takes none.

Plain stubs are published with --tag=legal-consent-views; the WireKit variants with --tag=legal-consent-wirekit. AI translation is an optional seam: bind Pushery\LegalConsent\Contracts\LegalTextTranslator to enable the editor's "Translate" action; unbound, it reports that no translator is configured.

7. Verify existing data after upgrading

Run the verifier once after migrating:

php artisan legal-consent:verify-documents

It re-derives every published row's hash and flags any row whose stored hash, notice mode, or wording locale disagrees with its content — so tampering or a pre-0.4.0 mislabeled ui_wording snapshot is visible rather than silent.

8. Adding a column to legal_documents

The immutability trigger from 000011 fails closed on PostgreSQL — a column added by a later migration is protected automatically. On MySQL and SQLite the trigger enumerates the columns present when it was created, so a column added later is not covered by it. A migration of yours that backfills or rewrites a legal_documents column must drop the trigger, apply the data change, and re-create the trigger — the guard aborts a migration-time UPDATE exactly as it aborts runtime tampering.