Skip to content
prod e051e98
Browse

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.

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.

Before building anything, decide WHAT pages you need and HOW to create them.

  1. Pick the required pages against the app’s footprint.

    PageRequired?Purpose
    Privacy PolicyYesHow you handle user data
    Terms of ServiceYesRules for using the service
    Cookie PolicyIf EU/UK trafficCookie-consent compliance
    Refund PolicyIf sellingPayment / refund terms
    • ✅ The list of pages this app actually needs is decided.
  2. 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.

    MethodCostBest for
    Generator service (recommended)Free–$27/yrMost SaaS — auto-updates when laws change
    Templates + customizeFreeBudget-conscious, manual effort
    Lawyer$500–2000+Funded startups, sensitive data
    • ✅ A creation method is chosen.

Choose a single generator and use it for Privacy, Terms, and the cookie banner — mixing providers fragments consent state.

  1. Sign up for one generator (👤) and grab its embed.

    ServiceCostBest for
    TermlyFree basic tierMVP — AI-driven templates, geo-targeting, auto-updates
    GetTerms$4.99/moSimple all-in-one with cookie scan
    IubendaFree / $27/yrMulti-language (15+) + EU / IAB TCF advertising
    • ✅ One generator is chosen and the embed UUID/URL is in hand.
  2. 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.

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.

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).

  1. 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>
    • /privacy and /terms load 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.

  1. 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.
  2. Wire the authenticated download route.

    // AccountController + route
    public 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/export downloads 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"]
  1. 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.
  2. 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.

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.

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).

  1. 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:

    CategoryAlways on?Examples
    EssentialYesSession, CSRF tokens
    AnalyticsNo — consent neededGoogle Analytics
    MarketingNo — consent neededFacebook Pixel
    FunctionalNo — consent neededChat widgets
    • ✅ A manage-preferences control sits in the footer and GDPR lets users change their choice later.
  2. 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.

Link the policies in the footer and gate signup on an explicit consent checkbox, then record when consent was given.

  1. 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.
  2. Record consent on registration — add a migration for terms_accepted_at (+ optional terms_accepted_ip, terms_version) and set it in the registration controller: $user->terms_accepted_at = now();.

    • terms_accepted_at populates when a user registers.

The documentation layer that lets you honestly claim “we are GDPR compliant.”

  1. 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.
  2. Obtain and store a signed DPA (👤) from every subprocessor (Stripe stripe.com/legal/dpa, Cloudflare, Sentry sentry.io/legal/dpa/, your ESP, your host). File as Vendor-DPA-YYYY-MM-DD.pdf.

    • ✅ Every subprocessor’s DPA is signed and filed.
  3. 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.
  4. Verify the data-subject rights end to end — export downloads valid JSON; a test deletion removes/anonymizes data after the grace window; terms_accepted_at populates on registration.

    • ✅ Export, deletion, and consent recording all work end to end.

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 + /terms load; 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_at recorded on registration.
  • 🔀 GDPR finalized — ROPA documented, DPAs filed for all subprocessors (👤), data-protection contact designated.