When a parent on our school platform submits a deletion request, an administrator approves it, and our server runs one function — anonymiseContact($contactId). That function took five attempts to get right.
This post is about what makes Article 17 of the GDPR (the "right to erasure") genuinely difficult to implement, the decision tree we eventually settled on, and the specific failure modes we hit along the way. The code is Laravel and Postgres, but the decisions are universal.
The naive implementation
The obvious first draft of "delete a user's personal data" is a single SQL statement:
RosterContact::where('id', $contactId)->delete();
There are three separate reasons this will fail or fail-you-later. Each points at a different design decision.
Problem 1: the NOT NULL foreign key trap
Our students table has this column:
$table->unsignedBigInteger('roster_contact_id'); // NOT NULL
Calling Student::where('roster_contact_id', $contactId)->update(['roster_contact_id' => null]) before deleting the contact produces:
SQLSTATE[23502]: Not null violation: 7 ERROR: null value in column
"roster_contact_id" of relation "students" violates not-null constraint
Every table that references the contact has the same problem: issues, leave_requests, consent_responses, meeting_bookings, students. A naive cascading delete would wipe all of them — which is wrong, because the school owns those records and is legally required to keep them (attendance history, complaint records, consent audit trail).
The fix is to make roster_contact_id nullable on every downstream table:
Schema::table('students', function (Blueprint $table) {
$table->unsignedBigInteger('roster_contact_id')->nullable()->change();
});
// ... same for leave_requests, consent_responses, meeting_bookings
Only then can you null the link while keeping the row.
Problem 2: the four-way decision
Once the FKs are nullable, every table the user touched needs a deliberate decision. There are four possible outcomes, not two:
| Outcome | When to use it | Example |
|---|---|---|
| Hard delete | Data has no value after erasure and belongs exclusively to the user | Access codes, push-notification tokens |
| Anonymise (keep the row, strip PII) | Data has operational or audit value but shouldn't identify the user | The contact record itself, messages authored by the user |
| Detach (null the FK, keep the row) | Data belongs to a different party (the school, the platform) but was linked to the user | Issues, leave requests, consent responses |
| Retain (untouched) | Data was never directly identifying | CSAT ratings linked via issue, aggregate metrics |
For our platform, the decision matrix looks like this:
Access codes → hard delete
Push tokens, device ID → null out (PII)
Contact record → anonymise (name → "Deleted Contact", PII nulled)
Issue messages → anonymise (null author_id, replace meta.actor_name)
Activity log entries → anonymise (scrub contact_name in JSON payload)
Issues, leaves,
consents, bookings → detach (null roster_contact_id, keep row)
Students → detach (school owns the student record)
CSAT responses → retain (never carried direct PII)
This matrix is worth publishing for your own users. Under Article 15 (right of access) and Article 30 (records of processing), a data subject can ask what happens when they exercise erasure. A vague "we delete your data" is not a satisfactory answer.
The implementation
Here is the core of anonymiseContact(), simplified. Note the order of operations and the transaction boundary:
private function anonymiseContact(int $contactId): void
{
$tenantId = tenant('id');
$contact = RosterContact::where('id', $contactId)->first();
if (! $contact) {
return;
}
$actor = auth()->user(); // the admin who approved
$school = School::where('tenant_id', $tenantId)->first();
// 1. Collect attachment file references BEFORE deleting rows
$attachmentFiles = IssueAttachment::whereHas('issue',
fn ($q) => $q->where('roster_contact_id', $contactId)
)->get(['disk', 'path'])->all();
// 2. Auto-close open issues + unassign (housekeeping — see below)
$this->autoCloseOpenIssues($contactId, $actor);
// 3. Cancel future meeting bookings
$this->cancelFutureBookings($contactId, $actor);
// 4. Core anonymisation inside a transaction
DB::transaction(function () use ($tenantId, $contactId, $contact) {
// Hard-delete access codes (no value after erasure)
AccessCode::where('roster_contact_id', $contactId)->delete();
// Anonymise messages authored by this contact
IssueMessage::where('author_type', RosterContact::class)
->where('author_id', $contactId)
->each(function (IssueMessage $m) {
$meta = $m->meta ?? [];
if (isset($meta['actor_name'])) {
$meta['actor_name'] = 'Deleted Contact';
}
$m->update([
'author_id' => null,
'author_type' => null,
'meta' => $meta,
]);
});
// Scrub contact name from activity log payloads
IssueActivity::whereIn('issue_id', Issue::where(
'roster_contact_id', $contactId
)->pluck('id'))->each(function (IssueActivity $a) {
$data = $a->data ?? [];
if (isset($data['contact_name'])) {
$data['contact_name'] = 'Deleted Contact';
}
$a->update(['data' => $data]);
});
// Detach — null the FK on downstream records
Issue::where('roster_contact_id', $contactId)
->update(['roster_contact_id' => null]);
LeaveRequest::where('roster_contact_id', $contactId)
->update(['roster_contact_id' => null]);
ConsentResponse::where('roster_contact_id', $contactId)
->update(['roster_contact_id' => null]);
MeetingBooking::where('roster_contact_id', $contactId)
->update(['roster_contact_id' => null]);
Student::where('roster_contact_id', $contactId)
->update(['roster_contact_id' => null]);
// Finally, anonymise the contact itself
$contact->update([
'name' => 'Deleted Contact',
'email' => null,
'phone' => null,
'external_id' => null,
'expo_push_token' => null,
'device_platform' => null,
'meta' => null,
'deactivated_at' => now(),
'revoke_reason' => 'erasure_request',
]);
});
// 5. Delete attachment files from disk AFTER the DB commits
foreach ($attachmentFiles as $f) {
try {
Storage::disk($f['disk'])->delete($f['path']);
} catch (\Throwable $e) {
// Orphaned file is safer than a ghost DB row. Log and move on.
Log::warning('Attachment delete failed', [...]);
}
}
}
There are four non-obvious decisions in this code.
File cleanup happens outside the transaction
Deleting a file from disk is not transactional. If the DB commits and the Storage::delete() call fails, you have an orphaned file — annoying but recoverable. If the DB is still mid-transaction and the file deletion succeeds but the DB then rolls back, you have a ghost DB row pointing at a non-existent file — user-visible errors and harder to clean up.
We favour the first failure mode. Orphaned files can be swept out-of-band; ghost rows break UI.
Attachment paths are collected before the transaction
Inside the transaction we run the DELETE on issue_attachments. That removes the rows we would otherwise query for their disk paths. Collecting paths before the transaction runs means we still have the list when the DB part succeeds.
Auto-housekeeping is separate from anonymisation
When a contact is erased, their open issues should close, their future meeting bookings should cancel, and their staff assignments should release. We do this before the anonymisation transaction, attributed to the approving admin, so each auto-close emits its own activity-log entry with a clear actor and a close_reason: contact_erased flag.
If we did it inside the anonymisation, the actor on those closures would end up as null or "Deleted Contact", which is an audit trail that says "something happened to an issue but no one did it." That's the opposite of what you want in a compliance artefact.
Retry semantics matter
Our first deployment of anonymiseContact() crashed on the NOT NULL FK described earlier. The DB transaction rolled back cleanly, but our DataPrivacyRequest row ended up flagged failed with the error message stored.
We added two flags to the admin UI:
- Failed requests show a "Retry & execute" button instead of "Approve & execute"
- The approve action accepts both
pendingandfailedstatuses
After fixing the schema (making FKs nullable), the admin clicked Retry and the request completed. Without that retry affordance, every fix would have required manual DB editing or asking the parent to submit a fresh request — both are terrible UX for the one operation that must feel exact.
What stays forever
One counterintuitive part of GDPR erasure: the record that an erasure happened must itself be kept.
We keep three types of records indefinitely:
- The
data_privacy_requestsrow — with the contact's name and email captured at request time. This is the compliance evidence. - A
privacy_erasureactivity-log entry — summary of what was erased (issues closed, bookings cancelled) attributed to the approving admin. - Backup snapshots that predate the erasure — these age out on their own 90-day rotation, but until they do, the PII is still in them. That's worth disclosing in your DPA.
Supervisory authorities under Article 33 expect to see records of requests and their handling. A user asking "when did you delete me and who approved it?" is a legitimate question with a legitimate answer: "on this date, by this admin, here's the evidence."
Publishing the decision tree
Every word of the matrix above is now in our user-facing Privacy Policy, under a subsection titled "What happens when your erasure request is approved." Publishing it serves three purposes:
- Users exercising the right get a realistic expectation
- Auditors reading the policy see the engineering has substance
- It forces internal clarity — you cannot publish what you haven't decided
If you are about to implement Article 17, do not start with the code. Start with the decision matrix, write it in user-facing language first, and let the code follow. The five attempts we took were largely about failing to do this in the right order.
Summary
GDPR erasure is an engineering decision with four outcomes per data type, not a binary one. The implementation needs nullable foreign keys, a deliberate order of operations, transaction boundaries that correctly separate DB and storage, separate housekeeping for operational side-effects, retry semantics for failure recovery, and an indefinite audit trail of the erasure itself. The naive DELETE FROM users produces the wrong legal outcome, the wrong operational outcome, and the wrong audit outcome at the same time.
Write the matrix first. Then write the code.
Top comments (0)