Dataretentie in Laravel: geplande opschoning, anonimisering en verwijderverzoeken
Geen enkel framework levert GDPR-retentie out of the box — Laravel ook niet. Zo bouw je voort op Prunable, voeg je anonimiseringslogica toe, regel je cascade-verwijdering over gerelateerde modellen en verwerk je verwijderverzoeken in productie.
Waarom geen enkel framework dit voor je oplost
Als je ons vorige artikel over AVG-dataretentie hebt gelezen, ken je de theorie: de rechtsgrondslag bepaalt de bewaartermijn, anonimisering is niet hetzelfde als verwijdering, en soft delete telt niet. Dit artikel gaat over wat er nodig is om dat allemaal in een echte applicatie te laten werken.
We hebben elk groot framework en CMS gecontroleerd — Laravel, Django, Rails, Symfony, WordPress, Drupal, Statamic, Craft. Geen ervan levert AVG-conforme dataretentie out of the box. WordPress heeft een handmatige wistool. Drupal heeft een community-module voor het opschonen van formulieren. Laravel heeft een Prunable-trait voor geplande harde verwijderingen. Maar een complete retentiepipeline — anonimisering, cascade-logica, audittrail, verwijderworkflow — bestaat nergens als ingebouwde functie.
Dat is geen tekortkoming. Retentieregels zijn te projectspecifiek voor een generieke oplossing. Wat je moet bewaren, hoe lang, en wat er gebeurt wanneer de termijn verloopt — dat hangt volledig af van je datamodel, je wettelijke verplichtingen en je bedrijfslogica. Het framework geeft je bouwstenen. Jij zet ze in elkaar.
We gebruiken Laravel als voorbeeld omdat dat is waar we dagelijks mee werken, maar de aanpak geldt voor elke stack.
Wat Laravel je standaard biedt
Laravel's Prunable-trait (beschikbaar sinds versie 8.50) laat je modellen markeren als "prunable" en een query definiëren die verlopen records identificeert:
class ContactSubmission extends Model
{
use Prunable;
public function prunable(): Builder
{
return static::where('created_at', '<=', now()->subMonths(6));
}
}
Plan model:prune om dagelijks te draaien, en Laravel doorloopt alle prunable modellen en verwijdert overeenkomende rijen. Eenvoudig, ingebouwd, geen afhankelijkheden.
Maar Prunable verwijdert alleen hele rijen. Het anonimiseert geen specifieke velden terwijl andere behouden blijven. Het weet niet dat het verwijderen van een gebruiker ook hun reacties, uploads en activiteitslogs moet opschonen. Het registreert niet wat er verwijderd is of waarom. Voor de AVG is Prunable een startpunt — geen complete oplossing.
Twee benaderingen: kies wat past
Niet elk project heeft een volledig retentieframework nodig. Een marketingsite met een contactformulier en een nieuwsbriefaanmelding is heel anders dan een e-commerceplatform met bestellingen, betalingsgeschiedenis en supporttickets.
Voor kleinere projecten (2–5 modellen met persoonsgegevens): een enkel gepland commando dat elke model en de bijbehorende actie voor verlopen records expliciet opsomt, is voldoende. Eén bestand, één plek om te controleren, makkelijk te begrijpen:
// Eén commando, expliciete regels, klaar in een middag
ContactSubmission::where('created_at', '<=', now()->subMonths(6))->delete();
Order::whereNull('anonymized_at')
->where('created_at', '<=', now()->subYears(2))
->each(fn (Order $order) => $order->anonymize());
Voor grotere projecten (10+ modellen, meerdere developers, doorlopende wijzigingen): je hebt een contract nodig. Een interface die elk model met persoonsgegevens moet implementeren:
interface Retainable
{
public function retentionPeriod(): CarbonInterval;
public function anonymize(): void;
public function isRetained(): bool;
}
Wanneer een nieuwe developer een model toevoegt dat e-mails of telefoonnummers opslaat, is het patroon duidelijk: implementeer de interface, voeg de anonimiseringsregels toe, schrijf de test. Zonder dit zijn retentiebeslissingen impliciet — ergens begraven in een commando, makkelijk te missen wanneer het datamodel verandert.
Begin eenvoudig, introduceer de abstractie wanneer herhaling fouten begint te veroorzaken.
Anonimisering: meer dan velden leegmaken
De meest voorkomende implementatiefout is anonimisering behandelen als "zet het e-mailadres op null." Het is genuanceerder dan dat.
Een bestelrecord moet de klantnaam, het e-mailadres en het verzendadres kwijtraken — maar de datum, het bedrag en de productcategorie behouden. Die financiële velden kunnen verplicht zijn voor belastingcompliance gedurende 7 jaar. De persoonlijke identificatoren worden na 2 jaar irrelevant. Dezelfde tabel, verschillende bewaartermijnen per veld:
public function anonymize(): void
{
$this->update([
'customer_name' => null,
'customer_email' => null,
'shipping_address' => null,
'phone' => null,
// amount, tax, product_category — onaangeraakt
'anonymized_at' => now(),
]);
}
Sommige velden worden leeggemaakt. Sommige worden vervangen door een generieke placeholder — "Geanonimiseerde Gebruiker" in plaats van een naam is handig wanneer het record nog in admin-interfaces of rapporten verschijnt. De anonymized_at timestamp laat je bijhouden wat verwerkt is en het overslaan bij volgende runs.
De kritieke toets: kijk na anonimisering naar alles wat er in het record overblijft. Kan de resterende combinatie van velden een persoon identificeren? Stad plus geboortedatum plus een nicheproductaankoop in een kleine stad — dat kan genoeg zijn. Anonimisering is niet veld-voor-veld. Het gaat om het totaalplaatje.
Cascade: het deel dat iedereen onderschat
Het verwijderen of anonimiseren van een gebruiker raakt één tabel. Maar de gegevens van die gebruiker leven op veel plaatsen: reacties die ze geplaatst hebben, bestellingen, activiteitslogs, geüploade bestanden op schijf of objectopslag, sessies, queued jobs, wachtwoordresettokens, notificatierecords.
Elk gerelateerd record heeft een expliciete beslissing nodig:
Verwijderen — activiteitslogs, sessies, wachtwoordtokens. Geen waarde zonder de persoon.
Anonimiseren — bestellingen (financiële data behouden, persoonlijke identificatoren verwijderen), reacties (auteursnaam vervangen door "Anoniem").
Bestanden verwijderen — geüploade avatars, documenten, bijlagen. Deze leven buiten de database, vaak in S3 of vergelijkbare opslag. Een databaseverwijdering raakt ze niet.
Bewaren met onderbouwing — een supportticket moet mogelijk om juridische redenen blijven. Documenteer waarom.
Het model moet zijn volledige cascade beheren — niet alleen zijn eigen velden:
public function anonymize(): void
{
$this->update([
'name' => 'Geanonimiseerde Gebruiker',
'email' => "anon-{$this->id}@removed.local",
'phone' => null,
'anonymized_at' => now(),
]);
$this->orders->each(fn (Order $o) => $o->anonymize());
$this->comments()->update(['author_name' => 'Anoniem']);
$this->activityLogs()->delete();
$this->uploads->each(function (Upload $upload) {
Storage::delete($upload->path);
$upload->delete();
});
}
De cascade-kaart is het belangrijkste artefact in je retentie-implementatie. Een praktische tip: doorzoek je databaseschema op elke foreign key die naar de users-tabel wijst. Controleer vervolgens tabellen die gebruikersgegevens opslaan zonder foreign key — gedenormaliseerde velden, JSON-kolommen, logrecords met e-mailadressen in vrije tekst. Dat is je echte cascade-kaart, en die is meestal groter dan je verwacht.
Recht op verwijdering: een workflow, geen knop
Geplande opschoning verwerkt data die op natuurlijke wijze is verlopen. Het recht op verwijdering is anders — het is een door de gebruiker geïnitieerd verzoek met een eigen proces en een antwoordtermijn van 30 dagen.
Stap 1: Verifieer identiteit. Een verzoek vanuit een geauthenticeerd account is eenvoudig. Een verzoek per e-mail van iemand die beweert een gebruiker te zijn niet — je moet bevestigen dat ze het echt zijn. Een bevestigingslink naar het geregistreerde e-mailadres is meestal voldoende. Het doel is voorkomen dat iemand de gegevens van een ander verwijdert.
Stap 2: Controleer op blokkades. Een actief abonnement, een onbetaalde factuur, een juridische bewaarplicht — dit zijn legitieme gronden om het verzoek gedeeltelijk of volledig af te wijzen. Belastinggegevens die 7 jaar bewaard moeten worden, worden niet verwijderd alleen omdat iemand erom vraagt. Maar je moet uitleggen wat je bewaart en waarom.
Stap 3: Voer de cascade uit. Doorloop de volledige datakaart. Elke tabel, elke relatie, elke bestandsopslaglocatie. Hier betaalt je cascade-kaart zich terug.
Stap 4: Informeer derde partijen. Stripe, Mailchimp, analytics-tools, CRM — overal waar persoonsgegevens naartoe zijn gestuurd, moet het verwijderverzoek volgen.
Stap 5: Log de actie. Registreer wat er gedaan is, wanneer, en welke datacategorieën betrokken waren. Log niet de daadwerkelijk verwijderde gegevens.
Stap 6: Reageer binnen 30 dagen. Zelfs als het antwoord is "we hebben gedeeltelijk afgewezen, hier is wat we bewaren, en hier is de juridische basis daarvoor."
Houd deze workflow in een dedicated service class — niet in een controller, niet in een console command. Het wordt vanuit beide aangeroepen (admin panel, self-service gebruiker, support team tooling), en het moet onafhankelijk testbaar zijn.
Backups: de ongemakkelijke waarheid
Je hebt de gebruiker uit de database verwijderd. Maar de backup van gisteren op S3 bevat nog steeds hun volledige record. Dit is de vraag waar elk team tegenop ziet — en het antwoord is redelijker dan je zou verwachten.
De AVG vereist niet dat je individuele records uit versleutelde backups schraapt. Dat zou technisch onpraktisch zijn voor de meeste systemen. Wat het wel vereist:
Een gedefinieerde bewaartermijn voor backups — 30 dagen is gebruikelijk. "Voor altijd, voor de zekerheid" is niet acceptabel.
Documentatie — je privacybeleid moet het backup-bewaarvenster vermelden.
Transparantie in verwijderreacties — "Je gegevens zijn verwijderd uit alle actieve systemen. Versleutelde backups roteren automatisch uit na 30 dagen."
Het sleutelwoord is "gedefinieerd." Een backup-venster van 90 dagen met gedocumenteerde onderbouwing is prima. Geen gedefinieerd venster is een compliance-gat.
Derde partijen: bouw het register nu
Data stroomt naar buiten: betalingsverwerkers, e-mailmarketingplatformen, supporttools, analytics, CRM. Als gegevensverantwoordelijke ben jij verantwoordelijk voor het doorsturen van een verwijderverzoek naar al deze partijen.
Het register moet bestaan voordat het eerste verzoek binnenkomt. Documenteer per derde partij: welke persoonsgegevens ze bewaren, hoe verwijdering werkt (API-call, handmatige e-mail, self-service dashboard), en wie je moet contacteren. Dit onder tijdsdruk uitzoeken tijdens je eerste verwijderverzoek is een recept voor gemiste deadlines.
Sommige diensten maken het makkelijk — Stripe laat je een klant verwijderen via API. Andere vereisen dat je hun privacyteam mailt en dagen wacht op een antwoord. Houd rekening met beide scenario's in je antwoordtijdlijn.
Testen: het vangnet dat je echt nodig hebt
Een anonimiseringsmethode die één veld mist, maakt het hele doel ongedaan. Dit is geen code die je shipt en vergeet.
Het meest waardevolle testpatroon: onderhoud een expliciete lijst van PII-velden op elk model, en verifieer dat anonymize() ze allemaal opschoont:
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-veld '{$field}' werd niet opgeschoond"
);
}
}
Deze test doet dubbel werk. Hij vangt bugs in je anonimiseringslogica vandaag, en hij vangt regressie morgen — wanneer een developer een nieuw phone of address veld aan het model toevoegt maar vergeet anonymize() bij te werken. Voeg het veld toe aan PII_FIELDS, en de test faalt totdat de anonimiseringslogica is bijgewerkt.
Naast individuele modellen: test de volledige cascade. Verifieer na het verwerken van een gebruiker dat reacties geanonimiseerd zijn, geüploade bestanden daadwerkelijk uit de opslag verdwenen zijn, en activiteitslogs verwijderd zijn. De cascade is waar dingen stilzwijgend breken.
Monitoring: bewijs dat het draait
Een geplande opschoning die stilzwijgend faalt is erger dan geen opschoning — omdat je denkt dat je compliant bent terwijl je dat niet bent.
Log elke run — niet alleen "commando voltooid," maar hoeveel records verwerkt, geanonimiseerd, verwijderd, en hoeveel fouten er optraden.
Alert bij afwezigheid — als de opschoning niet binnen het verwachte interval heeft gedraaid, is er iets kapot. Een dode scheduler is een stilzwijgend compliance-gat.
Alert bij groei — als het aantal records voorbij hun bewaartermijn groeit in plaats van krimpt, houdt de opschoning het niet bij.
Periodieke rapportage — een maandelijks overzicht voor degene die AVG-compliance beheert. Geen dashboard dat ze nooit bekijken — een e-mail of bericht dat ze daadwerkelijk zien.
Documentatie: bewijs dat je het goed hebt gedaan
De AVG vereist bewijs van compliance, niet alleen compliance zelf. Je project heeft minimaal nodig:
Retentieschema — welke datatypen, welke bewaartermijnen, welke rechtsgrondslag elk rechtvaardigt.
Datakaart — waar persoonsgegevens leven: databasetabellen, bestandsopslag, diensten van derden, backups.
Auditlog — records met tijdstempel van anonimiserings- en verwijderingsgebeurtenissen, zonder de daadwerkelijk verwijderde gegevens.
Register van derde partijen — welke externe diensten persoonsgegevens bewaren, hoe verwijdering bij elk werkt.
Verwijderprocedure — het stapsgewijze proces voor het afhandelen van verzoeken, inclusief antwoordsjablonen.
Deze documentatie dient twee doelgroepen. Toezichthouders kunnen het nodig hebben om compliance te verifiëren. Maar de meer directe lezer is de developer die volgend jaar bij je team komt en moet begrijpen waarom het opschoningscommando bestaat, wat het dekt, en wat er gebeurt als het stopt met draaien.
Kan ik Laravel's Prunable-trait gebruiken voor AVG-compliance?
Prunable is een goed startpunt voor geplande harde verwijderingen — zoals contactformulierinzendingen ouder dan zes maanden verwijderen. Maar het verwijdert alleen hele rijen. Het kan geen specifieke velden anonimiseren, cascade-logica over gerelateerde modellen afhandelen, of loggen wat er verwijderd is en waarom. Beschouw het als één bouwsteen, niet de volledige oplossing.
Wat als mijn project alleen een contactformulier en een nieuwsbrief heeft — heb ik dit dan allemaal nodig?
Nee. Een enkel gepland commando dat contactinzendingen ouder dan zes maanden verwijdert en uitgeschreven nieuwsbrief-e-mails verwijdert, is voldoende. De volledige interface-gebaseerde architectuur wordt waardevol wanneer je 10+ modellen met persoonsgegevens hebt en meerdere developers die consistente patronen moeten volgen.
Hoe ga ik om met verwijderverzoeken voor data die naar externe diensten zoals Stripe of Mailchimp is gestuurd?
Als gegevensverantwoordelijke ben je verantwoordelijk voor het doorsturen van het verwijderverzoek. Bouw een register van alle derde partijen voordat het eerste verzoek binnenkomt: welke data ze bewaren, hoe verwijdering werkt (API, handmatige e-mail, dashboard), en verwachte reactietijd. Sommige diensten zoals Stripe bieden API-gebaseerde verwijdering; andere vereisen dat je rechtstreeks contact opneemt met hun privacyteam.
Zijn er bestaande Laravel-pakketten die AVG-retentie afhandelen?
Er bestaan er een paar — zoals soved/laravel-gdpr en dialect/laravel-gdpr-compliance. Ze dekken basisfuncties zoals data-export, encryptie en opschoning van inactieve gebruikers. Maar geen ervan handelt per-veld anonimisering, cascade-regels voor gerelateerde records, of een volledig workflow voor het recht op verwijdering af. Je zult nog steeds de retentielogica moeten bouwen die past bij jouw specifieke datamodel.
Moet ik persoonsgegevens uit backups verwijderen wanneer iemand om verwijdering vraagt?
De AVG vereist niet dat je individuele records uit versleutelde backups schraapt — dat is technisch onpraktisch. Wat het wel vereist is een gedefinieerde, gedocumenteerde bewaartermijn voor backups (30–90 dagen is gebruikelijk) en transparantie: vertel de gebruiker dat versleutelde backups automatisch roteren binnen dat venster.
Retentielogica nodig?
Wij Regelen de Cleanup Code
Van anonimiseringsworkflows tot verwerking van verwijderverzoeken — wij bouwen de dataretentiepipeline zodat je Laravel-applicatie compliant blijft zonder handmatig werk.