Saying what changed
A notice that says "we have updated our terms" tells a reader nothing they could act on. Every change notice worth the name leads with what changed, and often as a list rather than a sentence: processors added, one removed, an optional one becoming required.
That description is authored per document and per locale, frozen onto the version when you publish, and rendered into whichever notice the version's mode produces.
Describe the change
use Pushery\LegalConsent\Facades\ChangeItems;
ChangeItems::for('terms', 'de')
->headline('Wir haben zwei Auftragsverarbeiter aufgenommen und § 7 angepasst.')
->impact('Deine Kontodaten werden künftig auch in Irland verarbeitet.')
->added(
subject: 'Cloudflare, Inc.',
partyName: 'Cloudflare, Inc.',
partyLocation: 'Irland (EU)',
partyContact: 'dpo@cloudflare.example',
purpose: 'Auslieferung statischer Inhalte und DDoS-Schutz',
)
->extended('Analytics', 'Bisher optional, künftig fester Bestandteil.')
->restricted('§ 7 Haftung', 'Haftung für einfache Fahrlässigkeit ausgeschlossen.')
->save();
Call it from a release script, a migration, or your own admin screen — it is a plain API, not a console wizard, because a change description is legal copy that should be reviewed before it exists.
A save replaces the draft whole, or not at all. Saving again for the same document and locale
replaces the description that was there rather than adding to it, in one transaction: when the
database refuses an entry, the save throws and the earlier description stays as it was.
ChangeItems::discard('terms', 'de') removes a draft the same way, its entries together with its
header.
A value is checked against its column when you describe it. subject, partyName,
partyLocation and partyContact are stored in string columns of 255 characters, or of the length
your application passes to Schema::defaultStringLength(). headline, impact, detail and
purpose are text columns, which hold 65,535 bytes on MySQL. A longer value throws
InvalidChangeDescription naming the argument, on every database and before anything is written,
where SQLite would otherwise store it and PostgreSQL or MySQL refuse it at the save. The value is
measured as it is stored, after runs of whitespace are collapsed, and a longer explanation belongs
in detail.
Two fields, not one. § 327r Abs. 2 Satz 2 Nr. 1 BGB wants the characteristics of the change; WP260 rev.01 Rz. 31 separately wants its likely impact on the reader. A package that offered one field would quietly satisfy half of that.
The six entry types
They are not a taxonomy of edits. They are the distinctions that decide what else the notice owes.
| Type | Means | Adverse? |
|---|---|---|
added | something new — a clause, a recipient, a purpose | no |
removed | something gone | yes |
modified | different content, neither plainly wider nor narrower | no |
clarified | the same rule, said more clearly | no |
extended | broader reach — an optional processor becoming mandatory | yes |
restricted | a right limited, a liability excluded | yes |
extended is the one a naive list misses. Something optional becoming mandatory reads as a bonus
and is a narrowing of the reader's choice — the sub-processors that "will become required" in a
typical vendor notice.
An adverse entry makes the free-termination line mandatory in every notice that binds on
silence, a deemed-consent notice and an information-only notice about a contract, whether or not you
set offers_termination: § 675g Abs. 2 Satz 3 BGB ties that notice to the change being
disadvantageous, not to a flag you remembered to set. A notice missing it is refused certification
and its legal_notices.mandatory_content_ok is false. A re-consent notice asks for agreement and
binds nobody by silence, so it carries no such line.
Third parties
added, removed and extended accept the facets EDPB Opinion 22/2024 Rz. 22 expects when a new
party starts handling personal data: who they are, where the data goes, whom to ask, and what they
do with it. They are named by legal function rather than as "sub-processor", so the same four
columns describe a new payment service provider or a joint controller.
They are reported when incomplete, never enforced. You may be describing a clause rather than a processor, and the package refusing your notice over that would be deciding what your change was about.
The notice prints the facets in parentheses after the entry. When the subject names something other
than the party, such as its role (subject: 'Neuer Auftragsverarbeiter'), the party's name leads
them; when the two agree, as in the example above, the name is printed once.
Publishing freezes it
legal-consent:publish and the atomic release both freeze the draft onto the version, inside the
same transaction. It is a transition of the same row, not a copy — so the description on file
and the description a subject read cannot drift apart. The frozen row also stores the version's
content_hash, binding the description to exactly the text it describes.
Frozen means frozen. A database trigger on PostgreSQL, MySQL and SQLite refuses UPDATE and
DELETE on a published row and on every one of its items, and on PostgreSQL a TRUNCATE of either
table while it holds a published row. MySQL runs no trigger on TRUNCATE TABLE. A correction is
described in the next version, exactly like the legal text itself.
A publish rolls back cleanly: your draft is still there.
Where it lands in the notice
As lines on the mail, before the call to action — a reader decides whether to click on the
strength of the delta, not after it. That placement is also what puts the delta into the
durable-medium proof: legal_notices.notice_body is built from the mail's own lines and hashed, so
a description rendered by swapping the mail view would reach the subject and be absent from the
record of what they were shown.
Requiring it
Off by default. A version with no description renders exactly the notice it rendered before.
// config/legal-consent.php
'change_items' => [
'required' => true,
],
With it on, a change that owes a notice cannot be released until every locale has a description with both fields — § 327r Abs. 1 Nr. 3 BGB, Art. 12(1) GDPR and DSA Art. 14(1) all require the reader's own language, and a release is one change across every locale. The pre-flight also reports a description written against a legal text that has since moved.
What it cannot see is a German field containing English text. That gap is real and the honest answer is editorial process, not a predicate.
Inspecting
php artisan legal-consent:changes terms de # print the pending description
php artisan legal-consent:changes terms de --clear # discard it