Building a GDPR-Compliant Multi-Tenant CRM with Laravel

Published: (April 3, 2026 at 03:59 PM EDT)
3 min read
Source: Dev.to

Source: Dev.to

Introduction

Building a CRM that handles personal data (names, emails, phone numbers, addresses) in the EU means you can’t treat GDPR as an afterthought. Below is how we implemented GDPR compliance in WB‑CRM, our multi‑tenant CRM built with Laravel 12.

Multi‑tenant Architecture

We use stancl/tenancy v3 with database‑per‑tenant isolation. Each tenant gets its own MySQL database (e.g., tenant_acme, tenant_demo, etc.).

// Central models explicitly set their connection
protected $connection = 'central';

// Tenant models rely on the bootstrapper — no $connection property
// stancl/tenancy switches the default connection automatically

Why not a shared database with row‑level security?

In a shared database, a missing WHERE tenant_id = ? clause can leak data across companies. With a DB‑per‑tenant approach, cross‑tenant data leakage is architecturally impossible.

Encryption of Personal Data

Laravel’s encrypted cast encrypts individual database fields with AES‑256:

protected function casts(): array
{
    return [
        'name'       => 'encrypted',
        'email'      => 'encrypted',
        'phone'      => 'encrypted',
        'address'    => 'encrypted',
        'ip_address' => 'encrypted',
    ];
}

Searching Encrypted Fields

Encrypted fields cannot be queried directly (WHERE email = ?). We solve this with companion hash columns:

// email_hash stores a HMAC‑SHA256 hash for lookups
$contact = Contact::where(
    'email_hash',
    hash_hmac('sha256', $email, config('app.key'))
)->first();

GDPR‑required Endpoints

RightArticleImplementation
AccessArt. 15JSON/CSV export endpoint with re‑authentication
RectificationArt. 16Profile edit functionality
ErasureArt. 17Account deletion + cascading DB cleanup
RestrictionArt. 18Account freeze (disable without delete)
PortabilityArt. 20Machine‑readable export (JSON)
ObjectionArt. 21Marketing opt‑out

Audit Logging

Every GDPR operation writes an audit log entry containing: who, when, what, which tenant, old values, and new values. This satisfies GDPR Art. 30 (records of processing activities).

TenantAuditLog::create([
    'uuid'           => Str::uuid(),
    'auditable_type'=> get_class($model),
    'auditable_id'  => (string) $model->uuid,
    'event'          => 'gdpr_data_export',
    'old_values'     => null,
    'new_values'     => [
        'format' => 'json',
        'fields' => $exportedFields,
    ],
    'user_type'      => TenantUser::class,
    'user_id'        => $tenantUser->id,
    'ip_address'     => request()->ip(),
    'user_agent'     => request()->userAgent(),
]);

Additional Operational Tips

  • Herd/CLI bcrypt incompatibility: CLI PHP and Herd PHP on macOS produce incompatible bcrypt hashes. Always set passwords from the web context.
  • Session storage: With database‑per‑tenant, sessions must be stored in the central database; otherwise they are lost when switching tenants.
  • ENUM columns: Avoid ENUM in migrations; adding a value requires recreating the column in MySQL. Use string columns with validation instead.
  • Custom columns in stancl/tenancy: If you add a column to the tenants table but forget to register it in getCustomColumns(), it silently lands in the JSON data column, leading to hard‑to‑track bugs.

Conclusion

We built WB‑CRM around these principles, offering a free ONE plan (500 contacts, 1 user). The service is built and hosted in Germany, ensuring compliance with EU data‑protection standards.

0 views
Back to Blog

Related posts

Read more »

It's all the same, PT 2...

Background I was trying to create a consistent API across “social” sites and noticed that the same patterns keep re‑appearing in both PHP and JavaScript implem...