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.
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 | $processed | When |
|---|---|---|
legal-consent:prune | rows removed | every retention sweep, sent last: a sweep that stops before its tamper-chain repair is done sends none |
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:dispatch-notices.canceled | versions held back | only 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.unreachable | subjects given up on | only 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.expired | versions not sent | only 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.unproved | subjects not deemed | only 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.late | subjects not deemed | only 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}");
}
});