DEV Community

Cover image for Building a GDPR-Compliant Multi-Tenant CRM with Laravel
WISSEN BERATUNG
WISSEN BERATUNG

Posted on

Building a GDPR-Compliant Multi-Tenant CRM with Laravel

Building a CRM that handles personal data (names, emails, phone numbers, addresses) in the EU means you can't treat GDPR as an afterthought. Here's how we implemented it in WB-CRM, our multi-tenant CRM built with Laravel 12.

1. Database-per-Tenant Architecture

We use stancl/tenancy v3 with database-per-tenant isolation. Each tenant gets their own MySQL database (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
Enter fullscreen mode Exit fullscreen mode

Why not shared database with row-level security?

In a shared database, one missing WHERE tenant_id = ? clause leaks data across companies. With DB-per-tenant, it's architecturally impossible.

2. Field-Level Encryption for PII

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',
    ];
}
Enter fullscreen mode Exit fullscreen mode

The tradeoff: You can't query encrypted fields with 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();
Enter fullscreen mode Exit fullscreen mode

3. GDPR Data Subject Rights in Code

Articles 15-21 are not just checkboxes — they need actual endpoints:

Right Article Implementation
Access Art. 15 JSON/CSV export endpoint with re-authentication
Rectification Art. 16 Profile edit functionality
Erasure Art. 17 Account deletion + cascading DB cleanup
Restriction Art. 18 Account freeze (disable without delete)
Portability Art. 20 Machine-readable export (JSON)
Objection Art. 21 Marketing opt-out

Every GDPR operation writes an audit log entry with: who, when, what, which tenant, old values, new values.

4. Audit Trail

Every state-changing operation produces an audit log entry. This is mandatory for 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(),
]);
Enter fullscreen mode Exit fullscreen mode

5. What I Wish I Knew Earlier

  • Herd/CLI bcrypt incompatibility: CLI PHP and Herd PHP on macOS produce incompatible bcrypt hashes. Always set passwords from the web context.
  • Session connection must be central: With database-per-tenant, sessions stored in a tenant DB are lost when switching tenants.
  • ENUM columns are evil in migrations: Adding a value requires recreating the column in MySQL. Use string columns with validation instead.
  • getCustomColumns() 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. Hours of debugging.

We built WB-CRM with these principles. Free ONE plan available (500 contacts, 1 user). Built and hosted in Germany.

Top comments (0)