Building a GDPR-Compliant Multi-Tenant CRM with Laravel
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 automaticallyWhy 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
| 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 |
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
tenantstable but forget to register it ingetCustomColumns(), 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.