6 · Legal, privacy & GDPR
Objective — make the launch lawful for EU/UK traffic: decide which legal pages you need, generate and host Privacy + Terms via one generator (GetTerms / Termly / Iubenda), wire a cookie-consent banner, build GDPR data export + deletion-with-grace-period, and finalize with ROPA + DPAs.
Background
Section titled “Background”If the app takes EU/UK traffic, it needs a Privacy Policy, Terms of Service, and a cookie-consent banner before launch — plus GDPR data-subject features. Here the angle is engineering + legal documents: generate the policies, host them, and build the export/deletion machinery.
1. Decide what you need
Section titled “1. Decide what you need”Before building anything, decide WHAT pages you need and HOW to create them.
-
Pick the required pages against the app’s footprint.
Page Required? Purpose Privacy Policy Yes How you handle user data Terms of Service Yes Rules for using the service Cookie Policy If EU/UK traffic Cookie-consent compliance Refund Policy If selling Payment / refund terms - ✅ The list of pages this app actually needs is decided.
-
Choose how to create them. A generator with an iframe/script embed is the fastest path — zero code changes means zero merge conflicts on vendor updates, and the policy auto-updates when laws change.
Method Cost Best for Generator service (recommended) Free–$27/yr Most SaaS — auto-updates when laws change Templates + customize Free Budget-conscious, manual effort Lawyer $500–2000+ Funded startups, sensitive data - ✅ A creation method is chosen.
2. Pick ONE legal generator
Section titled “2. Pick ONE legal generator”Choose a single generator and use it for Privacy, Terms, and the cookie banner — mixing providers fragments consent state.
-
Sign up for one generator (👤) and grab its embed.
Service Cost Best for Termly Free basic tier MVP — AI-driven templates, geo-targeting, auto-updates GetTerms $4.99/mo Simple all-in-one with cookie scan Iubenda Free / $27/yr Multi-language (15+) + EU / IAB TCF advertising - ✅ One generator is chosen and the embed UUID/URL is in hand.
-
Paste the embed as the first script in
<head>so it can block other scripts until consent — load on staging and production (not local) so EU testers see the banner before launch:<head>@env(['staging', 'production'])<script type="text/javascript"src="https://app.termly.io/embed.min.js"data-auto-block="on"data-website-uuid="{{ config('services.termly.uuid') }}"></script>@endenv</head>Store the UUID in
config/services.php/ env — never commit a literal placeholder ID.- ✅ The generator embed is the first
<head>script on staging and production.
- ✅ The generator embed is the first
GetTerms uses src="https://gettermscmp.com/cookie-consent/embed/YOUR_ID/en-us?auto=true"; Iubenda uses a _iub.csConfiguration block plus its autoblocking + iubenda_cs.js scripts. All three generate the policies and host them at a URL you link from the footer.
3. Host Privacy + Terms pages
Section titled “3. Host Privacy + Terms pages”If the generator gives a hosted URL, link straight to it. If you embed, add a route + view (only when one doesn’t already exist).
-
Add the legal routes + iframe view (only if no hosted URL).
routes/web.php Route::view('/privacy', 'legal.privacy')->name('privacy');Route::view('/terms', 'legal.terms')->name('terms');resources/views/legal/privacy.blade.php <iframe src="YOUR_GENERATOR_EMBED_URL" width="100%" height="800" frameborder="0"></iframe>- ✅
/privacyand/termsload in a browser.
- ✅
4. GDPR data export (right to portability)
Section titled “4. GDPR data export (right to portability)”Give users a one-click export of their data as JSON. Add an exportData() method to the User model and an authenticated download route.
-
Add
exportData()to the User model.app/Models/User.php public function exportData(): array{return ['profile' => $this->only(['name', 'email', 'phone', 'created_at', 'updated_at']),'invoices' => $this->invoices()->select(['id', 'amount', 'status', 'created_at'])->get()->toArray(),'subscriptions' => $this->subscriptions()->select(['stripe_id', 'stripe_status', 'created_at'])->get()->toArray(),'exported_at' => now()->toIso8601String(),];}- ✅ The model returns a structured export payload.
-
Wire the authenticated download route.
// AccountController + routepublic function exportData(Request $request){$filename = 'my-data-' . now()->format('Y-m-d') . '.json';return response()->json($request->user()->exportData())->header('Content-Disposition', 'attachment; filename="' . $filename . '"');}Route::middleware(['auth', 'throttle:6,1'])->get('/account/export', [AccountController::class, 'exportData'])->name('account.export');- ✅
/account/exportdownloads valid JSON for the signed-in user.
- ✅
5. GDPR deletion with a grace period (right to erasure)
Section titled “5. GDPR deletion with a grace period (right to erasure)”Don’t hard-delete on click. Flag the account, dispatch a delayed job, and let the user cancel within the window — a safety net against accidental or malicious deletion.
flowchart LR Req["User requests deletion"] --> Flag["Set deletion_requested_at = now()"] Flag --> Job["DeleteUserJob<br/>delayed 30 days"] Flag -. user changes mind .-> Cancel["cancelDeletion()<br/>clears the flag"] Job --> Check{Flag still set?} Check -->|yes| Del["Delete / anonymize all data + notify"] Check -->|no, cancelled| Skip["No-op"]-
Add the request + cancel methods to the User model — flag only, no delayed job.
app/Models/User.php public function requestDeletion(): void{$this->update(['deletion_requested_at' => now()]);$this->notify(new \App\Notifications\DeletionScheduled());}public function cancelDeletion(): void{$this->update(['deletion_requested_at' => null]);}- ✅ Deletion sets a flag; cancel clears it. The 30-day wait is enforced by a daily scan, not a delayed job.
-
Scan-and-delete on a daily schedule (the durable mechanism).
// routes/console.php (or App\Console\Kernel::schedule)Schedule::call(function () {\App\Models\User::whereNotNull('deletion_requested_at')->where('deletion_requested_at', '<=', now()->subDays(30))->each(fn ($u) => \App\Jobs\DeleteUserJob::dispatchSync($u->id));})->daily();- ✅ A daily query finds users past the 30-day window and deletes/anonymizes them;
cancelDeletion()removes them from the set before then.
- ✅ A daily query finds users past the 30-day window and deletes/anonymizes them;
DeleteUserJob re-checks deletion_requested_at is still set (not cancelled) before it deletes or anonymizes the data, then sends a confirmation. Both deletion actions are logged via Activity logging.
6. Cookie consent banner
Section titled “6. Cookie consent banner”If you picked a generator in section 2, the banner and cookie policy are already handled — this is verification + the manage-preferences link. If not, drop in a standalone solution (Cookiebot, spatie/laravel-cookie-consent, or a custom Blade component).
-
Add a manage-preferences control to the footer (use your provider’s method —
displayPreferenceModal()for Termly,Cookiebot.renew()for Cookiebot).<button type="button" onclick="displayPreferenceModal(); return false;">Manage Cookie Preferences</button>The cookie categories the banner gates:
Category Always on? Examples Essential Yes Session, CSRF tokens Analytics No — consent needed Google Analytics Marketing No — consent needed Facebook Pixel Functional No — consent needed Chat widgets - ✅ A manage-preferences control sits in the footer and GDPR lets users change their choice later.
-
Verify the banner in an incognito window (👤): banner appears on first visit, Accept/Reject both work and persist across refresh, and non-essential cookies stay blocked until accepted.
- ✅ Accept/Reject persist and non-essential cookies stay blocked until accepted.
7. Footer links + signup consent
Section titled “7. Footer links + signup consent”Link the policies in the footer and gate signup on an explicit consent checkbox, then record when consent was given.
-
Add footer links + a required consent checkbox.
<a href="{{ route('privacy') }}">Privacy Policy</a><a href="{{ route('terms') }}">Terms of Service</a><div class="form-check"><input type="checkbox" name="terms_accepted" id="terms_accepted" required><label for="terms_accepted">I agree to the<a href="{{ route('terms') }}" target="_blank">Terms of Service</a> and<a href="{{ route('privacy') }}" target="_blank">Privacy Policy</a></label></div>- ✅ Footer links render and signup requires the consent checkbox.
-
Record consent on registration — add a migration for
terms_accepted_at(+ optionalterms_accepted_ip,terms_version) and set it in the registration controller:$user->terms_accepted_at = now();.- ✅
terms_accepted_atpopulates when a user registers.
- ✅
8. Finalize GDPR — ROPA, DPAs, contact
Section titled “8. Finalize GDPR — ROPA, DPAs, contact”The documentation layer that lets you honestly claim “we are GDPR compliant.”
-
Document the Records of Processing Activities (ROPA) — each data category with its purpose, legal basis, retention, and recipients (account data, auth, payment data → Stripe, usage data, support, security logs), plus a data-flow diagram and the subprocessor list.
- ✅ A ROPA with a data-flow diagram and subprocessor list exists.
-
Obtain and store a signed DPA (👤) from every subprocessor (Stripe
stripe.com/legal/dpa, Cloudflare, Sentrysentry.io/legal/dpa/, your ESP, your host). File asVendor-DPA-YYYY-MM-DD.pdf.- ✅ Every subprocessor’s DPA is signed and filed.
-
Create the Data Protection Contact (👤) —
privacy@[DOMAIN], forwarded to a responsible owner, and add a Data Protection Contact section to the Privacy Policy + ROPA.- ✅
privacy@[DOMAIN]exists and is referenced in the policy + ROPA.
- ✅
-
Verify the data-subject rights end to end — export downloads valid JSON; a test deletion removes/anonymizes data after the grace window;
terms_accepted_atpopulates on registration.- ✅ Export, deletion, and consent recording all work end to end.
Checklist
Section titled “Checklist”Do not mark this step done until every box below is checked.
- 🔀 Generator embedded — pages decided; one generator chosen (👤) and embedded as the first
<head>script (@production). - 🔀 Policies live —
/privacy+/termsload; privacy policy names every third-party data recipient. - 🤖 GDPR export + deletion — export downloads JSON; deletion schedules a cancellable 30-day job (both logged).
- 🔀 Cookie banner verified — Accept/Reject persist (👤 incognito check); manage-preferences link in footer.
- 🤖 Footer + consent — footer links + signup consent checkbox live;
terms_accepted_atrecorded on registration. - 🔀 GDPR finalized — ROPA documented, DPAs filed for all subprocessors (👤), data-protection contact designated.