I am building LaraFoundry, a reusable Laravel SaaS core, in public. It is extracted from a CRM that already runs in production, and I ship it phase by phase. This post is phase 5.3, the legal and GDPR layer. It bundled editable legal pages, cookie consent, a Terms gate, personal-data export, and account erasure.
GDPR work is usually the least loved part of a SaaS. It lands at the end, as a checkbox, as something you bolt on once a customer in the EU asks. I wanted it to be a seam from the start instead, and building it that way surfaced something I did not expect: the right to access and the right to be forgotten are the same shape.
What shipped in phase 5.3
Five pieces, each with its own review pass:
- Legal pages: a super-admin editor for Terms, Privacy and Cookie policy, stored in the database, with versioning and a public
/legal/{slug}route. - Cookie consent: a banner that is off by default, plus the plumbing to record a decision for guests and authenticated users.
- Terms gate: middleware that asks a user to re-accept when the published Terms version changes.
- Data export: a synchronous JSON download of everything the app holds about a user.
- Account erasure: the right to be forgotten, as a grace-period soft-delete with an irreversible anonymise at the end.
The export and erasure pair is the interesting one, so it gets most of this article.
Export and erasure are the same seam
The same additive-provider idiom shows up all over LaraFoundry. The menu is built from menu providers. The dashboard is built from widget providers. So when it came to GDPR, I reached for the same idea twice.
A module that owns user data implements one small contract to export it:
interface ExportsUserDataProvider
{
// The exportable data this provider holds for the given user.
public function exportFor(Authenticatable $user): array;
// Unique section key under which this data is filed (e.g. 'profile', 'tickets').
public function key(): string;
public function priority(): int;
}
And the mirror contract to erase it:
interface PurgesUserData
{
// Erase or anonymise the data this purger owns. Must be idempotent.
public function purgeFor(Authenticatable $user): void;
public function key(): string;
public function priority(): int;
}
Look at them side by side. exportFor() returns a slice, purgeFor() destroys a slice, and everything else is identical. Two methods with the same signature pointing in opposite directions. The core ships providers for the identity it owns (profile, sessions, settings, consent). The notifications module ships a provider for the inbox. The tickets module ships one for tickets. None of them know about each other, and neither the export flow nor the erasure flow knows the full list.
That symmetry is the whole point. When I add an orders module later, I register one exporter and one purger, and both GDPR rights light up for orders at once. There is no central place to update, no checklist to forget. The compliance surface grows with the app, automatically, because access and forgotten are wired the same way.
A registry collects each side:
public function exportFor(Authenticatable $user): array
{
$sections = [];
foreach ($this->sortedProviders() as $provider) {
$sections[$provider->key()] = $provider->exportFor($user);
}
return $sections;
}
The export endpoint streams that as a JSON file, rate-limited so it cannot be hammered. The erasure registry walks the mirror set the same way.
Erasure is a grace period, not a DELETE
Deleting your account does not delete a row. It sets a user_deleted_at timestamp, signs you out, and hides you everywhere at once. That is a reversible soft-delete, and it starts a clock. A super-admin can still restore you during the window, which is the difference between a rage-quit you regret on Tuesday and a permanent loss.
The actual erasure happens later, on a daily cron:
$model::query()
->whereNotNull('user_deleted_at')
->whereNull('user_purged_at')
->where('user_deleted_at', '<', now()->subDays($graceDays))
->chunkById(100, function ($users) use ($registry): void {
foreach ($users as $user) {
DB::transaction(function () use ($registry, $user): void {
$registry->purge($user);
$user->forceFill(['user_purged_at' => now()])->save();
});
}
});
Three things matter here.
It anonymises, it does not hard-delete the row. The core purger blanks the name, rewrites the email to a reserved unreachable address, drops the password, the two-factor secrets, the OAuth tokens, revokes the sessions and personal access tokens, and removes the avatar file. The row survives, faceless. That keeps foreign keys intact and lets legal records that must survive (think invoices) stay attached to an anonymised identity instead of dangling.
It is idempotent. A purged account is stamped with user_purged_at and drops out of the query forever. A row whose purge threw half-way is left unstamped and retried on the next run, and because every purger is written to be safe to run twice, the retry just finishes the job.
And each provider decides delete versus anonymise for itself. Personal data the user owns gets deleted outright. Records that have to survive get anonymised and kept. The contract does not force one policy on everyone.
The one thing I deliberately do not erase
The activity log.
It would feel tidy to scrub every trace of a user on erasure. It is also the wrong move. The audit trail of what happened, including the fact that an erasure ran, is exactly the evidence you need to show you honoured the request. So identity is anonymised everywhere, but the log of actions stays. Anonymise the who, keep the what. That is the line, and it was a decision, not an oversight.
Legal pages, the Terms gate, and a banner that stays quiet
The legal pages reuse work from an earlier phase. They are edited in the admin console, stored in the database per language, and rendered through the same HTML sanitizer the email template editor uses, so a stored legal page cannot smuggle a script into a public page. Each save bumps a version.
That version feeds the Terms gate. It is a piece of middleware, and the important property is that it is fail-open. It only forces re-acceptance once a Terms page is actually published with a version. Until then it does nothing. You never get a redirect loop into a Terms page that does not exist yet, and a fresh install is not held hostage by a feature nobody configured. When you do publish and later bump the version, every signed-in user is asked to re-accept once, then continues exactly where they were.
The cookie banner ships off. The core sets only strictly necessary cookies (session, CSRF, locale, the appearance toggle), and those do not require consent under GDPR. So there is nothing to ask about until you add analytics or marketing cookies yourself. The banner, the recorded decision and the consent state are all wired and waiting, you just flip one config flag the day you actually need it. No dark-pattern banner nagging people about cookies you never set.
Tests, because that is the proof
Every phase ships with its tests green and an adversarial review pass before merge. Phase 5.3 brought the suite to 743 backend tests in Pest and 164 on the frontend. Then the whole layer was wired into the host app and covered again with its own integration test: a published legal page is public, the Terms gate redirects a stale user on a real route, registration requires the checkbox only when Terms are published, the erasure cron anonymises past the grace window, and the consent state reaches the frontend.
GDPR is one of those areas where "it looks done" and "it is correct" are far apart, so the tests are not decoration here. They are the difference between a checkbox and a feature.
Free core stays free
LaraFoundry's core is open. Everything in this post is in the package, snippets and all. The plan is a free core that funds itself through a separate paid add-on, not by paywalling the basics. GDPR plumbing is about as basic as it gets, so it lives in the free core where it belongs.
Follow along
- Star or follow the build on GitHub: https://github.com/dmitryisaenko/larafoundry
- Project and updates: https://larafoundry.com