Retention — turn it on, or nothing is ever deleted
retention_after_end (default 3 years) is a statement of policy, not an enforcement. The
sweep that acts on it — legal-consent:prune — is the one scheduled task this package does not
register for you:
// config/legal-consent.php
'schedule' => [
'prune' => true, // daily; off by default
],
Why it is off by default
It deletes, and an app upgrading into a new version must never silently start erasing records it has been accumulating. That makes it your decision — but it is a decision, and not making it means expired personal data is kept forever while the config says otherwise (storage limitation, Art. 5(1)(e) GDPR).
What it deletes
Deliberately narrow: only rows past the period that are superseded (a newer row exists for the same subject, document, and locale) or orphaned (the subject is gone).
A subject's current standing is never deleted by age — that row is the Art. 7(1) proof the live relationship rests on. The same rule covers the delivery-proof rows.
"Orphaned" is a column, not a relationship, and that distinction used to make the whole arm
unreachable. Deleting the person's own row leaves subject_type and subject_id exactly as they
were, so their consent rows were neither orphaned nor superseded — no newer row will ever arrive
for someone who is gone — and they were kept for good, with the ip address still in them. What
makes a row orphaned is the erasure below.
Erasing a person: Consent::forget()
When you delete an account, call it:
$erasure = Consent::forget($user);
// $erasure->consents rows stripped in the consent ledger
// $erasure->notices rows stripped in the delivery ledger
// $erasure->rechained rows whose tamper-chain link had to be recomputed
Everything naming the person goes — subject_type, subject_id, ip_address, user_agent,
request_id. Everything proving the consent stays: the document, its version, the frozen wording,
the action, the instant, and subject_token, the pseudonym that still ties the two ledgers
together after the account is gone (Art. 17(3)(b)/(e)).
It is not an update. Both ledgers refuse every UPDATE at the model and, on PostgreSQL and
MySQL, at a trigger — so each row is deleted and written again without those columns, at its
original id, inside one transaction. Rows carry subject_erased_at afterwards: a lawful change to
append-only evidence that leaves no trace is indistinguishable from tampering.
If you are clearing those columns yourself today, you are not: the ledgers refuse the update. If
you are deleting and re-inserting instead, check whether your version re-links the tamper chain.
The erased columns are inputs to the row hash, and a row's hash folds in its own link — so a
rewrite that keeps the old link leaves legal-consent:verify-ledger reporting tampering on every
erasure you have performed, permanently.
Alert on it having stopped
The sweep reports a heartbeat through LegalConsentMonitor, so you can alert on it having stopped.
That matters more here than for the other sweeps: a dispatch that stops running leaves visibly
missing mail, while a prune that stops running fails silently — nothing errors, data just stays.
The seam defaults to a no-op NullMonitor (bound in the provider), so every heartbeat is silently
discarded until the host app binds its own. Wire it to your metrics or heartbeat stack in a service
provider's register():
$this->app->bind(
\Pushery\LegalConsent\Contracts\LegalConsentMonitor::class,
\App\Legal\MyMonitor::class,
);
Your implementation receives heartbeat(string $task, int $processed) after each scheduled sweep.
$task is the command's own name, and three of the six names are sent only when a run failed:
$task | $processed | When |
|---|---|---|
legal-consent:prune | rows removed | every retention sweep |
legal-consent:dispatch-notices | notices dispatched | every notice sweep |
legal-consent:close-objection-windows | acceptances deemed | every objection sweep |
legal-consent:dispatch-notices.deficient | versions sent without their mandatory content | only on a failed run — § 308 Nr. 5 lit. b makes the silence warning a validity condition, so silence cannot bind against those notices at all; fix the wording and re-notify |
legal-consent:dispatch-notices.held | versions held back | only on a failed run — the audience exceeded notifications.max_recipients_per_run, so the notice is still owed |
legal-consent:close-objection-windows.unproved | subjects not deemed | only on a failed run — no delivered notice carrying the § 308 Nr. 5 lit. b warning is on record for them, and silence does not bind without it |
Alert on the last three by name. The plain heartbeat is sent before the failure branch in both commands, so a run that held a legally required notice back, or could not bind a population by silence, still sends its ordinary heartbeat with the count it did manage. The run also exits non-zero — Laravel's scheduler turns that into an exception your handler sees — but the heartbeat channel on its own cannot tell a full run from a partial one unless it watches for these names.