Skip to main content

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.

Do not hand-roll this

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$processedWhen
legal-consent:prunerows removedevery retention sweep
legal-consent:dispatch-noticesnotices dispatchedevery notice sweep
legal-consent:close-objection-windowsacceptances deemedevery objection sweep
legal-consent:dispatch-notices.deficientversions sent without their mandatory contentonly 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.heldversions held backonly on a failed run — the audience exceeded notifications.max_recipients_per_run, so the notice is still owed
legal-consent:close-objection-windows.unprovedsubjects not deemedonly 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.