Data Retention in Laravel: Scheduled Cleanup, Anonymization, and Erasure Requests
No framework ships with GDPR retention out of the box — Laravel included. Here's how to build on Prunable, add anonymization logic, handle cascade deletion across related models, and process Right to Erasure requests in production.
Why No Framework Solves This for You
If you've read our previous article on GDPR data retention, you know the theory: legal basis determines retention period, anonymization isn't the same as deletion, and soft delete doesn't count. This article is about what it takes to make all of that actually work in a real application.
We checked every major framework and CMS — Laravel, Django, Rails, Symfony, WordPress, Drupal, Statamic, Craft. None of them ship GDPR-compliant data retention out of the box. WordPress has a manual erase tool. Drupal has a community module for form purging. Laravel has a Prunable trait for scheduled hard deletes. But a complete retention pipeline — anonymization, cascade logic, audit trail, erasure workflow — doesn't exist as a built-in feature anywhere.
That's not a failing. Retention rules are too project-specific for a generic solution. What you need to keep, for how long, and what happens when the period expires — that depends entirely on your data model, your legal obligations, and your business logic. The framework gives you building blocks. You assemble them.
We'll use Laravel as the example here because it's what we work with daily, but the thinking applies to any stack.
What Laravel Gives You Out of the Box
Laravel's Prunable trait (available since version 8.50) lets you mark models as "prunable" and define a query that identifies expired records:
class ContactSubmission extends Model
{
use Prunable;
public function prunable(): Builder
{
return static::where('created_at', '<=', now()->subMonths(6));
}
}
Schedule model:prune to run daily, and Laravel walks through all prunable models and deletes matching rows. Simple, built-in, zero dependencies.
But Prunable only deletes entire rows. It doesn't anonymize specific fields while keeping others. It doesn't know that deleting a user should also clean up their comments, uploads, and activity logs. It doesn't record what was removed or why. For GDPR, Prunable is a starting point — not a complete solution.
Two Approaches: Pick the One That Fits
Not every project needs a full retention framework. A marketing site with a contact form and a newsletter signup is very different from an e-commerce platform with orders, payment history, and support tickets.
For smaller projects (2–5 models with personal data): a single scheduled command that explicitly lists each model and what to do with expired records is enough. One file, one place to check, easy to understand:
// One command, explicit rules, done in an afternoon
ContactSubmission::where('created_at', '<=', now()->subMonths(6))->delete();
Order::whereNull('anonymized_at')
->where('created_at', '<=', now()->subYears(2))
->each(fn (Order $order) => $order->anonymize());
For larger projects (10+ models, multiple developers, ongoing changes): you need a contract. An interface that every model with personal data must implement:
interface Retainable
{
public function retentionPeriod(): CarbonInterval;
public function anonymize(): void;
public function isRetained(): bool;
}
When a new developer adds a model that stores emails or phone numbers, the pattern is obvious: implement the interface, add the anonymization rules, write the test. Without it, retention decisions are implicit — buried in a command somewhere, easy to miss when the data model changes.
Start simple, introduce the abstraction when repetition starts causing mistakes.
Anonymization: More Than Nulling Fields
The most common implementation mistake is treating anonymization as "set the email to null." It's more nuanced than that.
An order record should lose the customer's name, email, and shipping address — but keep the date, amount, and product category. Those financial fields might be required for tax compliance for 7 years. The personal identifiers become irrelevant after 2 years. Same table, different retention periods per field:
public function anonymize(): void
{
$this->update([
'customer_name' => null,
'customer_email' => null,
'shipping_address' => null,
'phone' => null,
// amount, tax, product_category — untouched
'anonymized_at' => now(),
]);
}
Some fields get nulled. Some get replaced with a generic placeholder — "Anonymized User" instead of a name is useful when the record still appears in admin interfaces or reports. The anonymized_at timestamp lets you track what's been processed and skip it on subsequent runs.
The critical test: after anonymization, look at everything that's left in the record. Can the remaining combination of fields identify a person? City plus date of birth plus a niche product purchase in a small town — that might be enough. Anonymization isn't field-by-field. It's about the full picture.
Cascade: The Part Everyone Underestimates
Deleting or anonymizing a user touches one table. But that user's data lives in many places: comments they posted, orders they placed, activity logs, uploaded files on disk or object storage, sessions, queued jobs, password reset tokens, notification records.
Each related record needs an explicit decision:
Delete — activity logs, sessions, password tokens. No value without the person.
Anonymize — orders (keep financial data, remove personal identifiers), comments (replace author name with "Anonymous").
Delete files — uploaded avatars, documents, attachments. These live outside the database, often in S3 or similar storage. A database deletion doesn't touch them.
Keep with justification — a support ticket might need to stay for legal reasons. Document why.
The model should own its full cascade — not just its own fields:
public function anonymize(): void
{
$this->update([
'name' => 'Anonymized User',
'email' => "anon-{$this->id}@removed.local",
'phone' => null,
'anonymized_at' => now(),
]);
$this->orders->each(fn (Order $o) => $o->anonymize());
$this->comments()->update(['author_name' => 'Anonymous']);
$this->activityLogs()->delete();
$this->uploads->each(function (Upload $upload) {
Storage::delete($upload->path);
$upload->delete();
});
}
The cascade map is the most important artifact in your retention implementation. One practical tip: search your database schema for every foreign key pointing to the users table. Then check for tables that store user data without a foreign key — denormalized fields, JSON columns, log entries with email addresses in free text. That's your real cascade map, and it's usually bigger than you expect.
Right to Erasure: A Workflow, Not a Button
Scheduled cleanup handles data that has naturally expired. Right to Erasure is different — it's a user-initiated request with its own process and a 30-day response deadline.
Step 1: Verify identity. A request from an authenticated account is straightforward. A request sent by email from someone claiming to be a user is not — you need to confirm it's really them. A confirmation link to the registered email address is usually sufficient. The goal is to prevent someone from deleting another person's data.
Step 2: Check for blockers. An active subscription, an unpaid invoice, a legal hold — these are legitimate grounds to partially or fully decline the request. Tax records that must be retained for 7 years don't get deleted just because someone asked. But you must explain what you're keeping and why.
Step 3: Execute the cascade. Walk the full data map. Every table, every relationship, every file storage location. This is where your cascade map pays off.
Step 4: Notify third parties. Stripe, Mailchimp, analytics tools, CRM — wherever personal data has been sent, the deletion request must follow.
Step 5: Log the action. Record what was done, when, and which data categories were affected. Do not log the actual deleted data.
Step 6: Respond within 30 days. Even if the answer is "we partially declined, here's what we kept, and here's the legal basis for keeping it."
Keep this workflow in a dedicated service class — not in a controller, not in a console command. It will be called from both (admin panel, user self-service, support team tooling), and it needs to be testable independently.
Backups: The Uncomfortable Truth
You deleted the user from the database. But yesterday's backup on S3 still has their full record. This is the question every team dreads — and the answer is more reasonable than you'd expect.
GDPR does not require you to scrub individual records from encrypted backups. That would be technically impractical for most systems. What it does require:
A defined retention period for backups — 30 days is common. "Forever, just in case" is not acceptable.
Documentation — your privacy policy should mention the backup retention window.
Transparency in erasure responses — "Your data has been removed from all active systems. Encrypted backups rotate out automatically after 30 days."
The key word is "defined." Having a 90-day backup window with documented justification is fine. Having no defined window is a compliance gap.
Third Parties: Build the Registry Now
Data flows outward: payment processors, email marketing platforms, support tools, analytics, CRM. As the data controller, you're responsible for ensuring a deletion request reaches all of them.
The registry should exist before the first request arrives. For each third party, document: what personal data they hold, how deletion works (API call, manual request, self-service dashboard), and who to contact. Discovering this under time pressure during your first erasure request is a recipe for missed deadlines.
Some services make it easy — Stripe lets you delete a customer via API. Others require emailing a privacy team and waiting days for a response. Factor both scenarios into your response timeline.
Testing: The Safety Net You Actually Need
An anonymization method that misses one field defeats the entire purpose. This isn't code you ship and forget.
The most valuable test pattern: maintain an explicit list of PII fields on each model, and assert that anonymize() clears every one of them:
public function test_order_anonymize_clears_all_pii(): void
{
$order = Order::factory()->create();
$order->anonymize();
$order->refresh();
foreach (Order::PII_FIELDS as $field) {
$this->assertNull(
$order->{$field},
"PII field '{$field}' was not cleared"
);
}
}
This test does double duty. It catches bugs in your anonymization logic today, and it catches regression tomorrow — when a developer adds a new phone or address field to the model but forgets to update anonymize(). Add the field to PII_FIELDS, and the test fails until the anonymization logic is updated.
Beyond individual models: test the full cascade. After processing a user, verify that comments are anonymized, uploaded files are gone from storage, and activity logs are purged. The cascade is where things silently break.
Monitoring: Prove That It's Running
A scheduled cleanup that silently fails is worse than no cleanup at all — because you believe you're compliant when you're not.
Log every run — not just "command finished," but how many records were processed, anonymized, deleted, and how many errors occurred.
Alert on absence — if the cleanup hasn't run within its expected interval, something is broken. A dead scheduler is a silent compliance gap.
Alert on growth — if the count of records past their retention period is growing instead of shrinking, the cleanup isn't keeping up.
Periodic reporting — a monthly summary for whoever owns GDPR compliance. Not a dashboard they'll never check — an email or message they'll actually see.
Documentation: Prove You Did It Right
GDPR requires proof of compliance, not just compliance itself. Your project needs at minimum:
Retention schedule — which data types, which retention periods, which legal basis justifies each one.
Data map — where personal data lives: database tables, file storage, third-party services, backups.
Audit log — timestamped records of anonymization and deletion events, without the actual deleted data.
Third-party registry — which external services hold personal data, how deletion works for each one.
Erasure procedure — the step-by-step process for handling requests, including response templates.
This documentation serves two audiences. Regulators may need it to verify compliance. But the more immediate reader is the developer who joins your team next year and needs to understand why the cleanup command exists, what it covers, and what happens if it stops running.
Can I use Laravel's Prunable trait for GDPR compliance?
Prunable is a good starting point for scheduled hard deletes — like removing contact form submissions older than six months. But it only deletes entire rows. It can't anonymize specific fields, handle cascade logic across related models, or log what was removed and why. Think of it as one building block, not the full solution.
What if my project only has a contact form and a newsletter — do I still need all of this?
No. A single scheduled command that deletes contact submissions older than six months and removes unsubscribed newsletter emails is enough. The full interface-based architecture becomes valuable when you have 10+ models with personal data and multiple developers who need to follow consistent patterns.
How do I handle erasure requests for data sent to third-party services like Stripe or Mailchimp?
As the data controller, you're responsible for forwarding the deletion request. Build a registry of all third parties before the first request arrives: what data they hold, how deletion works (API, manual email, dashboard), and expected response time. Some services like Stripe offer API-based deletion; others require contacting their privacy team directly.
Are there existing Laravel packages that handle GDPR retention?
A few exist — like soved/laravel-gdpr and dialect/laravel-gdpr-compliance. They cover basics like data export, encryption, and inactive user cleanup. But none of them handle per-field anonymization, cascade rules for related records, or a full Right to Erasure workflow. You'll still need to build the retention logic that matches your specific data model.
Do I have to delete personal data from backups when someone requests erasure?
GDPR doesn't require scrubbing individual records from encrypted backups — that's technically impractical. What it does require is a defined, documented backup retention period (30–90 days is common) and transparency: tell the user that encrypted backups rotate out automatically within that window.
Need retention logic built?
We'll Handle the Cleanup Code
From anonymization workflows to erasure request handling — we build the data retention pipeline so your Laravel application stays compliant without manual effort.