The request arrives in support: delete everything you hold on me. Someone forwards it to a developer, the developer finds the user row, and deletes it. Ticket closed.
It usually is not closed. Here is what that one line misses, and what handling it properly actually involves in a Laravel app.
1. Soft deletes do not delete
If the model uses SoftDeletes, the row is still there. Name, email, phone, date of birth, all intact, just filtered out of your queries. Nothing has happened from a data protection standpoint. You have hidden the data from yourself, which is arguably worse, because everyone now believes it is gone.
$user->delete(); // with SoftDeletes: sets deleted_at, row remains
$user->forceDelete(); // actually removes the row
Know which one your models do before you promise anyone anything.
2. Deletion is often the wrong outcome
The instinct is to remove the row. But the business usually has to keep invoices for a statutory period, and an orphaned invoice with a dangling user_id helps nobody.
The right shape is field-level, not row-level. Some fields get erased, some anonymised, some kept with a stated reason.
$user->forceFill([
'name' => 'Deleted user',
'email' => hash('sha256', $user->email . config('privacy.subject_salt')),
'phone' => null,
])->save();
Two notes on that hash. Use a dedicated secret rather than APP_KEY, because rotating the app key would orphan every value you have already written. And be honest about what it is: a deterministic hash still lets you test whether a specific known address was erased, which is useful operationally and means the result is pseudonymised, not anonymised. If you do not need that capability, null the column instead.
The same caution applies to the word anonymised generally. Removing a name while keeping a date of birth, a postcode and a purchase history does not make someone unidentifiable. Sweeney's 2000 paper found that 87% of the 1990 US population was uniquely identifiable from 5-digit ZIP, gender and full date of birth; Golle's 2006 recomputation on 2000 census data put the figure closer to 63%. Either way, keep enough quasi-identifiers and you have pseudonymised, and the obligations still apply.
3. Related records do not follow automatically
Orders, tickets, addresses, comments, saved cards, uploaded files. Some should be erased with the subject, some should not.
Cascades will not decide this for you. A database-level ON DELETE CASCADE deletes things you needed to keep, and no cascade at all leaves personal data sitting in child tables. You need an explicit per-relation decision, written down somewhere the code can read.
4. Files are data too
An uploaded ID scan in S3 is the most sensitive thing most apps hold, and it survives every database operation you perform. So do avatars, signed PDFs, and anything in a private disk whose path lives in a column you just anonymised. Anonymise the path first and the file becomes unreachable and permanent.
5. Your logs hold more than you think
Laravel logs. Webhook receipts. Activity and audit tables. Telescope, if it is on in production.
Queued jobs are more nuanced than they are usually described. With the SerializesModels trait, Laravel serialises only the model identifier and re-fetches the record when the job runs, so a job holding a User is not storing that user's attributes. What does get stored in full is any scalar you passed to the constructor, so new SendWelcomeEmail($user->email) puts the address straight into the payload. Failed jobs keep that payload alongside the exception message, and exception messages quote data surprisingly often.
You do not have to solve all of this, but you do have to know about it. Retention periods on logs and on failed_jobs are the usual answer: a bounded window makes this a bounded problem.
6. Third parties hold copies
Your payment processor has a customer record. Your mailing tool has a subscriber. Your support desk has a contact with the whole conversation. Your analytics tool may have user-level events.
Erasing your own database while three processors keep the data is not erasure, it is tidying. Each needs a call or a manual step, and you need a record of which ones were done.
7. Backups
The question that stalls every conversation. You cannot rewrite a nightly snapshot to remove one person, and regulators do not expect you to.
The ICO's position is that backup data can be put beyond use rather than deleted immediately, on four conditions: you do not use it to inform any decision about the individual, you give no one else access to it, it is properly secured, and you commit to deleting it when that becomes possible. In practice that means backups expire on a defined rotation, and a restore re-applies pending erasures before the data returns to production. Write that down as a policy. What you cannot do is leave it unexamined and hope it never comes up.
8. Order matters, and not everything can be rolled back
Follow relations before touching the parent, or foreign keys will fight you halfway through and leave the subject half-erased. Half-erased is the worst state available: the data is still there, and your records claim it is not.
Wrap the database work in a transaction. Do not wrap the rest in it. Deleting an S3 object and calling a third-party API cannot be rolled back, so run those after the commit, record the outcome of each, and let the request stay open while any of them is unresolved.
9. Prove it happened
The part almost everyone skips. When an enterprise customer's security questionnaire or a regulator asks what you did, "we deleted it" is not evidence.
What you want is an append-only log recording which models and record ids were affected, which action was applied to each, when, and who authorised it, holding no personal data itself. A hash of the subject identifier rather than the identifier. It is a small table, and it is the difference between a story and a record.
What a real implementation looks like
Personal data declared on the model rather than living in someone's head. A preview you can read before anything runs. Per-field strategies, with retained fields carrying their reason. Explicit per-relation decisions. File handling. Processor notifications with a completion state. Database work in a transaction, side effects after it, all of it logged.
That is a week of work the first time, and it is work almost nobody schedules until a request is already sitting in the inbox with a deadline on it.
I am building this as a Filament plugin: intake with due dates, a preview that also surfaces unmapped tables matching the subject, erasure that respects retention, and an evidence pack at the end.
If that would save you the week, the waitlist is here: https://madalitso-nyemba.kit.com/821bab10f0?utm_source=devto&utm_campaign=erasure-post. First 50 get early access pricing.
One question I would genuinely like answered in the comments: when a request last came in, how did you handle it?
Top comments (0)