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
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',
];
}
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();
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(),
]);
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 ingetCustomColumns(), it silently lands in the JSONdatacolumn. 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)