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
],

Start a sweep by hand with --isolated, as in php artisan legal-consent:prune --isolated. The scheduled sweeps run with it too, so both take the same lock: a run you start while the scheduled one is running reports that the command is already running and stops, and a scheduled run that comes due while yours is running does the same.

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 that are superseded (a newer row exists for the same subject, document, and locale) or orphaned (the subject is gone), once the period has passed since they stopped being relevant. A superseded row's period runs from the acceptance of the row that superseded it, so an acceptance that held for longer than the period is still kept for the whole period after it was replaced. An orphaned row's period runs from its own acceptance.

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.

It also deletes a person's row in the token registry once no record carries their token any more, and says how many it deleted.

A longer period for one document​

Some acceptances have to stay provable for longer than three years. An agreement that self-billed invoices rest on is one: the invoices are kept for eight years (§ 14b UStG, § 147 AO), and with them the proof that the agreement was in place first. retention_by_document gives such a document a period of its own:

// config/legal-consent.php
'retention_by_document' => [
'partner_terms' => '10 years',
],

The sweep then keeps the superseded and orphaned records of partner_terms, and its delivery proofs, for ten years after they stopped being relevant: a superseded acceptance for ten years after the acceptance that replaced it. Every other document keeps retention_after_end. A longer period still ends, which is what storage limitation asks of it. The value takes the same forms as retention_after_end, and one that is not a period stops the sweep before it deletes anything; legal-consent:doctor names the document.

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
// $erasure->unverifiedChains chains that no longer verified, erased but not re-linked

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)).

A notice row keeps its body. It is the text that was sent, the durable-medium proof a deemed acceptance rests on, and its hash cannot be rewritten. The shipped notices build that text from the document alone, so it names nobody. A notification class of your own that writes the recipient into a line (notice_mail.notification.{mode}) leaves them in the body after forget(), and the delivery proof logs a warning the first time it sees such a body. Keep the recipient out of the lines if forget() is to remove them.

Their row in the token registry goes as well, in every tenant: it is what tied the person to that pseudonym (one token per person). The erasure takes those rows before it reads their records, also where the records predate the registry. A write for the person that is under way finishes first and is erased with the rest. One that starts while the erasure runs waits for it and is stored afterwards, as a new record under a new pseudonym that nothing ties to the erased ones, so call forget() once nothing writes for the person any more, as when you delete the account.

The erasure re-links the person's tamper chain only when that chain still verifies as the package wrote it. A chain somebody changed outside the package is erased all the same, but its links are left as they are and counted in unverifiedChains, so legal-consent:verify-ledger goes on reporting what changed it. legal-consent:prune applies the same check: it leaves such a chain alone, expired rows included, and fails the run. While a ledger boundary, or the count of the records below one, does not verify, it removes no consent record at all and fails the run: a record removed from below a boundary frees its id, and the count, which the sweep lowers by every record it removes there, is what shows a row inserted under one.

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.

A failed write puts the person into your log​

forget() reaches the ledgers, not your log, and a write that fails in the database puts the person there. Laravel reports the failed statement as a QueryException whose message carries the statement with every bound value filled in: for a consent record that is the IP address, the user agent, the subject's key and its subject_token, in whatever your exception handler writes.

From laravel/framework 13.27.0 on, a connection can keep the placeholders in that message instead:

// config/database.php
'connections' => [
'pgsql' => [
// …
'mask_bindings_in_exception_messages' => true,
],
],

Set it on the connection the ledgers write through, which is your default connection. Before 13.27.0 the framework has no such option, and the values reach the message whatever you set.

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 seven of the ten names are sent only when a run failed:

$task$processedWhen
legal-consent:prunerows removedevery retention sweep, sent last: a sweep that stops before its tamper-chain repair is done sends none
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:dispatch-notices.canceledversions held backonly on a failed run — a NoticeDispatching listener held the notice back, so it is still owed; it goes out on the first run the listener lets through, and --force does not reach a listener
legal-consent:dispatch-notices.unreachablesubjects given up ononly on a failed run — their notice failed notifications.max_attempts times and is still owed; correct the address and run legal-consent:renotify, or erase the subject
legal-consent:dispatch-notices.expiredversions not sentonly on a failed run — a deemed-consent version still owed its notice after the objection deadline had passed; such a notice can found no acceptance, so nothing is sent until a new version with a new deadline replaces it
legal-consent:close-objection-windows.unprovedsubjects not deemedonly on a failed run — no notice carrying the § 308 Nr. 5 lit. b warning is on record as delivered to them by the objection deadline, and silence does not bind without it
legal-consent:close-objection-windows.latesubjects not deemedonly on a failed run — their notice was delivered too late to leave them the objection period, after the objection deadline or too close to it; they need a new version with a new deadline

Alert on the last seven 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.

A sweep the scheduler skips sends no heartbeat at all. Each sweep is scheduled with withoutOverlapping(), and a run that died holding its lock, through a SIGKILL or an out-of-memory kill, keeps the next one from starting until the lock expires. Laravel logs nothing when it skips a run that way, but it dispatches Illuminate\Console\Events\ScheduledTaskSkipped, so a listener sees the skipped sweep at once, where a heartbeat alert sees it only when its own window has passed:

use Illuminate\Console\Events\ScheduledTaskSkipped;
use Illuminate\Support\Facades\Event;

Event::listen(function (ScheduledTaskSkipped $event): void {
if (str_contains((string) $event->task->command, 'legal-consent:')) {
report("The scheduler skipped {$event->task->command}");
}
});