(Organizations, Contacts, Interactions – Schema + Code for Laravel, Django, Spring Boot, ASP.NET Core, Node.js, Drupal)
Introduction
ArchPilot has a very specific goal: to show how the same business problem can be solved across different technology ecosystems, while keeping a consistent data model and clear business rules.
In Step 1 – User & Role Management, we built the security backbone:
- Secure login (email/password, lockout, optional MFA)
- Roles and permissions (e.g.,
ManagerwithViewBudget) - Protected endpoints and pages
- Audit logs and onboarding states
In this Step 2 – Client Registry (CRM), we build the relational core of the business:
- Organizations – the companies/customers you work with.
- Contacts – people linked to those organizations.
- Interactions – history of calls, meetings, emails, and notes tied to organizations and contacts.
We keep the same ArchPilot philosophy:
- Use one canonical schema (
organizations,contacts,organization_contact,interactions). - Implement it in six different stacks: Laravel, Django, Spring Boot, ASP.NET Core, Node.js + Express, Drupal.
- Wire everything into the RBAC from Step 1 with permissions like
ViewClient,EditClient,ViewInteraction,EditInteraction. - Reuse
audit_logsfrom Step 1 to track who did what, when, and on which client or contact.
Step 2 is intended as a reusable CRM building block. Same schema, same behavior, multiple ecosystems. From there, you can compare:
- How each framework models entities (migrations, models, entities, schemas).
- How routes/pages are protected (middleware, decorators, policies, permissions).
- How a realistic flow looks: client list, client detail page, interaction log.
Later steps (Project & Budget Tracker, Approval Workflow, Audit & Versioning, Alerts, Reporting) will build on top of this same Client Registry across all frameworks.
What Step 2 Does (Simple Summary)
Business capabilities:
- Organizations
- Create / edit / archive organizations (e.g., “ACME Corp”).
- Key attributes: legal name, display name, website, sector, size band, tax ID, owner (account manager).
- Contacts
- Create / edit people (e.g., “Jane Smith – CFO”).
- Email (often unique), phone/mobile, job title, and functional role (e.g., billing contact, decision maker).
- Org–Contact Relationships
- One contact may be associated with multiple organizations (consultant, advisor).
- Role per organization (e.g., CFO for ACME, Board Member for Beta Ltd).
- Interactions
- Log phone calls, meetings, emails, internal notes.
- Can be attached to an organization, a contact, or both.
- Channel, subject, body, outcome, timestamp.
- Permissions
ViewClient,EditClientfor organizations/contacts.ViewInteraction,EditInteractionfor interaction history.- No permission → no client data or interactions.
- Audit
- Every critical change (
create_org,update_contact,create_interaction) is written toaudit_logs(Step 1).
- Every critical change (
Canonical Data Model (Shared by All Frameworks)
New CRM entities:
- organizations — companies/customers.
- contacts — people.
- organization_contact — M:N relation between organizations and contacts, plus a role.
- interactions — logged history of calls, meetings, emails, and notes.
Logical relationships:
users (1..*)──(0..*) organizations -- owner/responsible
users (1..*)──(0..*) contacts -- owner/responsible
organizations (1..*)──(0..*) organization_contact (0..*)──(1..*) contacts
organizations (1..*)──(0..*) interactions
contacts (1..*)──(0..*) interactions
users (1..*)──(0..*) interactions -- who performed/logged the interaction
Permissions in permissions.slug (from Step 1):
ViewClient,EditClientViewInteraction,EditInteraction
Roles in roles.slug:
- Example:
Managerwith all 4 permissions above.
Canonical SQL DDL (PostgreSQL-Friendly)
(For MySQL/SQL Server: use CHAR(36) or uniqueidentifier instead of UUID, and adjust defaults accordingly.)
-- ORGANIZATIONS
CREATE TABLE organizations (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
legal_name VARCHAR(200) NOT NULL,
display_name VARCHAR(160) NOT NULL,
website VARCHAR(200),
sector VARCHAR(80), -- e.g., Manufacturing, SaaS, Retail
size_band VARCHAR(40), -- e.g., 1-10, 11-50, 51-200
tax_id VARCHAR(50), -- VAT / EIN / company tax code
owner_user_id UUID, -- account manager
is_active BOOLEAN NOT NULL DEFAULT TRUE,
created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(),
updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(),
FOREIGN KEY (owner_user_id) REFERENCES users(id) ON DELETE SET NULL
);
CREATE INDEX idx_organizations_display_name ON organizations(display_name);
CREATE INDEX idx_organizations_sector ON organizations(sector);
-- CONTACTS
CREATE TABLE contacts (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
first_name VARCHAR(80) NOT NULL,
last_name VARCHAR(80) NOT NULL,
email VARCHAR(190),
phone VARCHAR(40),
mobile VARCHAR(40),
job_title VARCHAR(120),
owner_user_id UUID,
is_active BOOLEAN NOT NULL DEFAULT TRUE,
created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(),
updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(),
FOREIGN KEY (owner_user_id) REFERENCES users(id) ON DELETE SET NULL
);
CREATE UNIQUE INDEX idx_contacts_email_unique
ON contacts(email)
WHERE email IS NOT NULL;
-- ORGANIZATION_CONTACT (M:N)
CREATE TABLE organization_contact (
organization_id UUID NOT NULL,
contact_id UUID NOT NULL,
role_label VARCHAR(120), -- e.g. 'CFO', 'Billing Contact'
relationship_status VARCHAR(40), -- e.g. 'Active','Prospect','Former'
PRIMARY KEY (organization_id, contact_id),
FOREIGN KEY (organization_id) REFERENCES organizations(id) ON DELETE CASCADE,
FOREIGN KEY (contact_id) REFERENCES contacts(id) ON DELETE CASCADE
);
-- INTERACTIONS
CREATE TABLE interactions (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
organization_id UUID,
contact_id UUID,
user_id UUID, -- who logged/performed the interaction
channel VARCHAR(40) NOT NULL, -- call,email,meeting,note
subject VARCHAR(200) NOT NULL,
body TEXT,
outcome VARCHAR(80), -- e.g., 'Follow-up scheduled', 'Closed lost'
happened_at TIMESTAMP WITH TIME ZONE NOT NULL,
created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(),
FOREIGN KEY (organization_id) REFERENCES organizations(id) ON DELETE SET NULL,
FOREIGN KEY (contact_id) REFERENCES contacts(id) ON DELETE SET NULL,
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE SET NULL
);
CREATE INDEX idx_interactions_org ON interactions(organization_id);
CREATE INDEX idx_interactions_contact ON interactions(contact_id);
CREATE INDEX idx_interactions_happened_at ON interactions(happened_at);
Why This Schema?
- Separate organizations and contacts
- Many contacts per organization and the same person can be linked to multiple organizations.
- M:N link table (organization_contact)
- Avoids duplicating contacts to represent multiple customer relationships.
- Interactions “soft-linked”
- You can log items attached only to an organization, only to a contact, or both.
- Owner per entity
owner_user_idsupports “account owner” logic and per-owner filters.
- Consistent permissions
- Uses the same
permissionsandrolesdesign from Step 1 (e.g.,ViewClient) to control visibility.
- Uses the same
Indicative Business Rules
- Contact email uniqueness
- One email per system, or per tenant later (if you go multi-tenant).
- Archival vs deletion
- Use
is_activefor logical archive. - For stronger privacy (e.g., GDPR), you may add soft delete or anonymization.
- Use
- Visibility rules
- Default: users with
ViewClientsee all clients. - Enterprise: filter by
owner_user_idor team/region for more granular access.
- Default: users with
- Audit logging
- Write
create_org,update_org,create_contact,create_interactionintoaudit_logswith meaningful metadata.
- Write
Framework-by-Framework Implementation
Below we hook the canonical schema into each stack and implement:
- Schema/migrations/models
- Basic listing and detail views
- Interaction logging
- Permission checks (RBAC from Step 1)
Laravel (PHP)
Migrations
// database/migrations/2025_11_06_100000_create_crm_tables.php
return new class extends Migration {
public function up(): void
{
Schema::create('organizations', function (Blueprint $t) {
$t->uuid('id')->primary();
$t->string('legal_name', 200);
$t->string('display_name', 160);
$t->string('website', 200)->nullable();
$t->string('sector', 80)->nullable();
$t->string('size_band', 40)->nullable();
$t->string('tax_id', 50)->nullable();
$t->uuid('owner_user_id')->nullable();
$t->boolean('is_active')->default(true);
$t->timestampsTz();
$t->foreign('owner_user_id')
->references('id')->on('users')
->onDelete('set null');
$t->index('display_name');
$t->index('sector');
});
Schema::create('contacts', function (Blueprint $t) {
$t->uuid('id')->primary();
$t->string('first_name', 80);
$t->string('last_name', 80);
$t->string('email', 190)->nullable()->unique();
$t->string('phone', 40)->nullable();
$t->string('mobile', 40)->nullable();
$t->string('job_title', 120)->nullable();
$t->uuid('owner_user_id')->nullable();
$t->boolean('is_active')->default(true);
$t->timestampsTz();
$t->foreign('owner_user_id')
->references('id')->on('users')
->onDelete('set null');
});
Schema::create('organization_contact', function (Blueprint $t) {
$t->uuid('organization_id');
$t->uuid('contact_id');
$t->string('role_label', 120)->nullable();
$t->string('relationship_status', 40)->nullable();
$t->primary(['organization_id', 'contact_id']);
$t->foreign('organization_id')
->references('id')->on('organizations')
->onDelete('cascade');
$t->foreign('contact_id')
->references('id')->on('contacts')
->onDelete('cascade');
});
Schema::create('interactions', function (Blueprint $t) {
$t->uuid('id')->primary();
$t->uuid('organization_id')->nullable();
$t->uuid('contact_id')->nullable();
$t->uuid('user_id')->nullable();
$t->string('channel', 40);
$t->string('subject', 200);
$t->text('body')->nullable();
$t->string('outcome', 80)->nullable();
$t->timestampTz('happened_at');
$t->timestampTz('created_at')->useCurrent();
$t->foreign('organization_id')->references('id')->on('organizations')->onDelete('set null');
$t->foreign('contact_id')->references('id')->on('contacts')->onDelete('set null');
$t->foreign('user_id')->references('id')->on('users')->onDelete('set null');
$t->index('organization_id');
$t->index('contact_id');
$t->index('happened_at');
});
}
public function down(): void
{
Schema::dropIfExists('interactions');
Schema::dropIfExists('organization_contact');
Schema::dropIfExists('contacts');
Schema::dropIfExists('organizations');
}
};
Models
// app/Models/Organization.php
class Organization extends Model
{
use HasUuids;
protected $fillable = [
'legal_name','display_name','website','sector',
'size_band','tax_id','owner_user_id','is_active',
];
public function owner()
{
return $this->belongsTo(User::class, 'owner_user_id');
}
public function contacts()
{
return $this->belongsToMany(Contact::class)
->withPivot(['role_label','relationship_status']);
}
public function interactions()
{
return $this->hasMany(Interaction::class);
}
}
// app/Models/Contact.php
class Contact extends Model
{
use HasUuids;
protected $fillable = [
'first_name','last_name','email','phone','mobile',
'job_title','owner_user_id','is_active',
];
public function owner()
{
return $this->belongsTo(User::class, 'owner_user_id');
}
public function organizations()
{
return $this->belongsToMany(Organization::class)
->withPivot(['role_label','relationship_status']);
}
public function interactions()
{
return $this->hasMany(Interaction::class);
}
}
// app/Models/Interaction.php
class Interaction extends Model
{
use HasUuids;
public $timestamps = false;
protected $fillable = [
'organization_id','contact_id','user_id',
'channel','subject','body','outcome',
'happened_at','created_at',
];
protected $dates = ['happened_at','created_at'];
public function organization(){ return $this->belongsTo(Organization::class); }
public function contact(){ return $this->belongsTo(Contact::class); }
public function user(){ return $this->belongsTo(User::class); }
}
Seed CRM Permissions
// database/seeders/CrmPermissionSeeder.php
use Spatie\Permission\Models\Role;
use Spatie\Permission\Models\Permission;
class CrmPermissionSeeder extends Seeder
{
public function run()
{
$perms = [
'ViewClient',
'EditClient',
'ViewInteraction',
'EditInteraction',
];
foreach ($perms as $name) {
Permission::firstOrCreate(['name' => $name]);
}
$manager = Role::firstOrCreate(['name' => 'Manager']);
$manager->givePermissionTo($perms);
}
}
Controllers (Listing + Detail + Interaction Logging)
// routes/web.php
Route::middleware(['auth'])->group(function () {
Route::get('/organizations', [OrganizationController::class, 'index']);
Route::get('/organizations/{org}', [OrganizationController::class, 'show']);
Route::post('/organizations/{org}/interactions', [InteractionController::class, 'store']);
});
// app/Http/Controllers/OrganizationController.php
class OrganizationController extends Controller
{
public function index(Request $request)
{
if (! $request->user()->can('ViewClient')) {
abort(403, 'Missing permission: ViewClient');
}
$orgs = Organization::query()
->where('is_active', true)
->orderBy('display_name')
->take(50)
->get();
return view('organizations.index', compact('orgs'));
}
public function show(Request $request, Organization $org)
{
if (! $request->user()->can('ViewClient')) {
abort(403);
}
$interactions = $org->interactions()
->latest('happened_at')
->take(20)
->get();
return view('organizations.show', compact('org', 'interactions'));
}
}
// app/Http/Controllers/InteractionController.php
class InteractionController extends Controller
{
public function store(Request $request, Organization $org)
{
$user = $request->user();
if (! $user->can('EditInteraction')) {
abort(403);
}
$data = $request->validate([
'channel' => 'required|string|max:40',
'subject' => 'required|string|max:200',
'body' => 'nullable|string',
'outcome' => 'nullable|string|max:80',
'happened_at' => 'required|date',
'contact_id' => 'nullable|uuid|exists:contacts,id',
]);
$interaction = Interaction::create([
'organization_id' => $org->id,
'contact_id' => $data['contact_id'] ?? null,
'user_id' => $user->id,
'channel' => $data['channel'],
'subject' => $data['subject'],
'body' => $data['body'] ?? null,
'outcome' => $data['outcome'] ?? null,
'happened_at' => $data['happened_at'],
'created_at' => now(),
]);
AuditLog::create([
'user_id' => $user->id,
'action' => 'create_interaction',
'target_type' => 'interaction',
'target_id' => $interaction->id,
'metadata' => [
'organization_id' => $org->id,
'contact_id' => $interaction->contact_id,
'channel' => $interaction->channel,
],
'ip' => $request->ip(),
'ua' => $request->userAgent(),
]);
return redirect()->back()->with('status', 'Interaction logged.');
}
}
Laravel strengths for this step
- Very fast CRUD + UI development (Blade, Livewire, Nova/Filament).
- Great fit for internal CRM/admin panels.
Trade-offs
- For complex, multi-tenant CRM with heavy reporting you must be disciplined with architecture and caching.
Django (Python)
Models
# crm/models.py
import uuid
from django.db import models
from django.conf import settings
class Organization(models.Model):
id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
legal_name = models.CharField(max_length=200)
display_name = models.CharField(max_length=160)
website = models.URLField(max_length=200, blank=True)
sector = models.CharField(max_length=80, blank=True)
size_band = models.CharField(max_length=40, blank=True)
tax_id = models.CharField(max_length=50, blank=True)
owner_user = models.ForeignKey(
settings.AUTH_USER_MODEL,
null=True, blank=True,
on_delete=models.SET_NULL,
related_name='owned_organizations'
)
is_active = models.BooleanField(default=True)
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
class Meta:
indexes = [
models.Index(fields=['display_name']),
models.Index(fields=['sector']),
]
def __str__(self):
return self.display_name
class Contact(models.Model):
id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
first_name = models.CharField(max_length=80)
last_name = models.CharField(max_length=80)
email = models.EmailField(max_length=190, unique=True, null=True, blank=True)
phone = models.CharField(max_length=40, blank=True)
mobile = models.CharField(max_length=40, blank=True)
job_title = models.CharField(max_length=120, blank=True)
owner_user = models.ForeignKey(
settings.AUTH_USER_MODEL,
null=True, blank=True,
on_delete=models.SET_NULL,
related_name='owned_contacts'
)
is_active = models.BooleanField(default=True)
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
def __str__(self):
return f"{self.first_name} {self.last_name}"
class OrganizationContact(models.Model):
organization = models.ForeignKey(Organization, on_delete=models.CASCADE)
contact = models.ForeignKey(Contact, on_delete=models.CASCADE)
role_label = models.CharField(max_length=120, blank=True)
relationship_status = models.CharField(max_length=40, blank=True)
class Meta:
unique_together = ('organization', 'contact')
class Interaction(models.Model):
CHANNEL_CHOICES = [
('call', 'Call'),
('email', 'Email'),
('meeting', 'Meeting'),
('note', 'Note'),
]
id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
organization = models.ForeignKey(Organization, null=True, blank=True, on_delete=models.SET_NULL)
contact = models.ForeignKey(Contact, null=True, blank=True, on_delete=models.SET_NULL)
user = models.ForeignKey(settings.AUTH_USER_MODEL, null=True, blank=True, on_delete=models.SET_NULL)
channel = models.CharField(max_length=40, choices=CHANNEL_CHOICES)
subject = models.CharField(max_length=200)
body = models.TextField(blank=True)
outcome = models.CharField(max_length=80, blank=True)
happened_at = models.DateTimeField()
created_at = models.DateTimeField(auto_now_add=True)
class Meta:
indexes = [
models.Index(fields=['happened_at']),
models.Index(fields=['organization']),
models.Index(fields=['contact']),
]
Permissions (CRM-specific)
Example snippet to create custom permissions:
from django.contrib.auth.models import Permission
from django.contrib.contenttypes.models import ContentType
from .models import Organization, Interaction
def create_crm_permissions():
org_ct = ContentType.objects.get_for_model(Organization)
int_ct = ContentType.objects.get_for_model(Interaction)
Permission.objects.get_or_create(
codename='view_client',
name='Can view clients',
content_type=org_ct,
)
Permission.objects.get_or_create(
codename='edit_client',
name='Can edit clients',
content_type=org_ct,
)
Permission.objects.get_or_create(
codename='view_interaction',
name='Can view interactions',
content_type=int_ct,
)
Permission.objects.get_or_create(
codename='edit_interaction',
name='Can edit interactions',
content_type=int_ct,
)
Views – List, Detail, Log Interaction
# crm/views.py
from django.contrib.auth.decorators import login_required, permission_required
from django.shortcuts import render, get_object_or_404, redirect
from django.utils import timezone
from .models import Organization, Interaction, Contact
@login_required
@permission_required('crm.view_client', raise_exception=True)
def organization_list(request):
orgs = Organization.objects.filter(is_active=True).order_by('display_name')[:50]
return render(request, 'crm/organization_list.html', {'organizations': orgs})
@login_required
@permission_required('crm.view_client', raise_exception=True)
def organization_detail(request, pk):
org = get_object_or_404(Organization, pk=pk)
interactions = Interaction.objects.filter(organization=org).order_by('-happened_at')[:20]
return render(request, 'crm/organization_detail.html', {
'organization': org,
'interactions': interactions,
})
@login_required
@permission_required('crm.edit_interaction', raise_exception=True)
def log_interaction(request, pk):
org = get_object_or_404(Organization, pk=pk)
if request.method == 'POST':
channel = request.POST['channel']
subject = request.POST['subject']
body = request.POST.get('body', '')
outcome = request.POST.get('outcome', '')
happened_at = request.POST.get('happened_at') or timezone.now()
contact_id = request.POST.get('contact_id')
contact = Contact.objects.filter(pk=contact_id).first() if contact_id else None
interaction = Interaction.objects.create(
organization=org,
contact=contact,
user=request.user,
channel=channel,
subject=subject,
body=body,
outcome=outcome,
happened_at=happened_at,
)
# AuditLog.objects.create(...) # Reuse Step 1 audit table/model
return redirect('crm:organization_detail', pk=org.pk)
return render(request, 'crm/log_interaction.html', {'organization': org})
Django strengths for this step
- Admin + generic views = very fast internal CRM.
permission_requireddecorator simplifies access control.
Trade-offs
- Object-level permissions (per-org or per-owner) require extra tools like
django-guardianor custom logic.
Spring Boot (Java)
Entities (excerpt)
// Organization.java
@Entity
@Table(name = "organizations")
public class Organization {
@Id
@GeneratedValue
private UUID id;
@Column(name = "legal_name", nullable = false, length = 200)
private String legalName;
@Column(name = "display_name", nullable = false, length = 160)
private String displayName;
private String website;
private String sector;
private String sizeBand;
private String taxId;
@Column(name = "owner_user_id")
private UUID ownerUserId;
private boolean isActive = true;
@Column(name = "created_at")
private OffsetDateTime createdAt = OffsetDateTime.now();
@Column(name = "updated_at")
private OffsetDateTime updatedAt = OffsetDateTime.now();
// getters/setters
}
// Interaction.java
@Entity
@Table(name = "interactions")
public class Interaction {
@Id
@GeneratedValue
private UUID id;
@Column(name = "organization_id")
private UUID organizationId;
@Column(name = "contact_id")
private UUID contactId;
@Column(name = "user_id")
private UUID userId;
private String channel;
private String subject;
@Column(columnDefinition = "TEXT")
private String body;
private String outcome;
@Column(name = "happened_at")
private OffsetDateTime happenedAt;
@Column(name = "created_at")
private OffsetDateTime createdAt = OffsetDateTime.now();
// getters/setters
}
Repositories
public interface OrganizationRepository extends JpaRepository<Organization, UUID> {
List<Organization> findTop50ByIsActiveTrueOrderByDisplayNameAsc();
}
public interface InteractionRepository extends JpaRepository<Interaction, UUID> {
List<Interaction> findTop20ByOrganizationIdOrderByHappenedAtDesc(UUID orgId);
}
Controller with @PreAuthorize
@RestController
@RequestMapping("/api/crm")
@RequiredArgsConstructor
public class CrmController {
private final OrganizationRepository organizationRepository;
private final InteractionRepository interactionRepository;
private final AuditService auditService;
@PreAuthorize("hasAuthority('ViewClient')")
@GetMapping("/organizations")
public List<Organization> listOrganizations() {
return organizationRepository.findTop50ByIsActiveTrueOrderByDisplayNameAsc();
}
@PreAuthorize("hasAuthority('ViewClient')")
@GetMapping("/organizations/{id}")
public Map<String, Object> showOrganization(@PathVariable UUID id) {
Organization org = organizationRepository.findById(id)
.orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND));
List<Interaction> interactions =
interactionRepository.findTop20ByOrganizationIdOrderByHappenedAtDesc(id);
Map<String, Object> result = new HashMap<>();
result.put("organization", org);
result.put("interactions", interactions);
return result;
}
@PreAuthorize("hasAuthority('EditInteraction')")
@PostMapping("/organizations/{id}/interactions")
public Interaction createInteraction(
@PathVariable UUID id,
@RequestBody CreateInteractionRequest dto,
Authentication auth) {
UUID userId = ((MyUserPrincipal) auth.getPrincipal()).getId();
Interaction interaction = new Interaction();
interaction.setOrganizationId(id);
interaction.setContactId(dto.getContactId());
interaction.setUserId(userId);
interaction.setChannel(dto.getChannel());
interaction.setSubject(dto.getSubject());
interaction.setBody(dto.getBody());
interaction.setOutcome(dto.getOutcome());
interaction.setHappenedAt(dto.getHappenedAt());
Interaction saved = interactionRepository.save(interaction);
auditService.log(
"create_interaction",
userId,
"interaction",
saved.getId(),
Map.of("organizationId", id, "channel", dto.getChannel())
);
return saved;
}
}
Spring Boot strengths for this step
- Enterprise-grade IAM, SSO, and policy engines.
- Very strong fit for large, regulated CRM/ERP deployments.
Trade-offs
- More verbose and heavier initial setup compared to Laravel/Django.
- Requires architectural discipline from day one.
ASP.NET Core (C#)
EF Core Entities (excerpt)
public class Organization
{
public Guid Id { get; set; }
public string LegalName { get; set; } = default!;
public string DisplayName { get; set; } = default!;
public string? Website { get; set; }
public string? Sector { get; set; }
public string? SizeBand { get; set; }
public string? TaxId { get; set; }
public Guid? OwnerUserId { get; set; }
public bool IsActive { get; set; } = true;
public DateTimeOffset CreatedAt { get; set; } = DateTimeOffset.UtcNow;
public DateTimeOffset UpdatedAt { get; set; } = DateTimeOffset.UtcNow;
}
public class Interaction
{
public Guid Id { get; set; }
public Guid? OrganizationId { get; set; }
public Guid? ContactId { get; set; }
public Guid? UserId { get; set; }
public string Channel { get; set; } = default!;
public string Subject { get; set; } = default!;
public string? Body { get; set; }
public string? Outcome { get; set; }
public DateTimeOffset HappenedAt { get; set; }
public DateTimeOffset CreatedAt { get; set; } = DateTimeOffset.UtcNow;
}
public class AppDbContext : DbContext
{
public DbSet<Organization> Organizations => Set<Organization>();
public DbSet<Interaction> Interactions => Set<Interaction>();
protected override void OnModelCreating(ModelBuilder b)
{
b.Entity<Organization>()
.HasIndex(x => x.DisplayName);
// Map other constraints/indexes as needed
}
}
Controller with Policies
[ApiController]
[Route("api/[controller]")]
public class CrmController : ControllerBase
{
private readonly AppDbContext _db;
private readonly IAuditLogger _audit;
public CrmController(AppDbContext db, IAuditLogger audit)
{
_db = db;
_audit = audit;
}
[HttpGet("organizations")]
[Authorize(Policy = "ViewClient")]
public async Task<IActionResult> GetOrganizations()
{
var orgs = await _db.Organizations
.Where(o => o.IsActive)
.OrderBy(o => o.DisplayName)
.Take(50)
.ToListAsync();
return Ok(orgs);
}
[HttpPost("organizations/{id:guid}/interactions")]
[Authorize(Policy = "EditInteraction")]
public async Task<IActionResult> CreateInteraction(Guid id, [FromBody] CreateInteractionDto dto)
{
var userId = User.FindFirst("sub")?.Value;
var interaction = new Interaction
{
Id = Guid.NewGuid(),
OrganizationId = id,
ContactId = dto.ContactId,
UserId = userId is null ? null : Guid.Parse(userId),
Channel = dto.Channel,
Subject = dto.Subject,
Body = dto.Body,
Outcome = dto.Outcome,
HappenedAt = dto.HappenedAt
};
_db.Interactions.Add(interaction);
await _db.SaveChangesAsync();
await _audit.LogAsync("create_interaction", interaction.Id, new {
OrganizationId = id,
interaction.Channel
});
return Ok(interaction);
}
}
ASP.NET Core strengths for this step
- High performance and great tooling.
- Tight integration with Microsoft identity (Entra ID), SQL Server, Azure.
Trade-offs
- Requires .NET skills in the team.
- Mapping roles/permissions into claims and policies must be carefully designed.
Node.js + Express (Prisma ORM)
Prisma Schema
model Organization {
id String @id @default(uuid())
legal_name String
display_name String
website String?
sector String?
size_band String?
tax_id String?
owner_user_id String?
is_active Boolean @default(true)
created_at DateTime @default(now())
updated_at DateTime @updatedAt
contacts OrganizationContact[]
interactions Interaction[]
}
model Contact {
id String @id @default(uuid())
first_name String
last_name String
email String? @unique
phone String?
mobile String?
job_title String?
owner_user_id String?
is_active Boolean @default(true)
created_at DateTime @default(now())
updated_at DateTime @updatedAt
organizations OrganizationContact[]
interactions Interaction[]
}
model OrganizationContact {
organization Organization @relation(fields: [organization_id], references: [id])
organization_id String
contact Contact @relation(fields: [contact_id], references: [id])
contact_id String
role_label String?
relationship_status String?
@@id([organization_id, contact_id])
}
model Interaction {
id String @id @default(uuid())
organization Organization? @relation(fields: [organization_id], references: [id])
organization_id String?
contact Contact? @relation(fields: [contact_id], references: [id])
contact_id String?
user_id String?
channel String
subject String
body String?
outcome String?
happened_at DateTime
created_at DateTime @default(now())
}
Authorization Middleware
// middleware/authz.js
function requirePermission(perm) {
return (req, res, next) => {
const perms = req.user?.permissions || [];
if (!perms.includes(perm)) {
return res.status(403).json({ error: 'Missing permission: ' + perm });
}
next();
};
}
module.exports = { requirePermission };
Routes
// routes/crm.js
const router = require('express').Router();
const { prisma } = require('../prismaClient');
const { requireLogin } = require('../middleware/authn');
const { requirePermission } = require('../middleware/authz');
router.get(
'/organizations',
requireLogin,
requirePermission('ViewClient'),
async (req, res, next) => {
try {
const orgs = await prisma.organization.findMany({
where: { is_active: true },
orderBy: { display_name: 'asc' },
take: 50,
});
res.json(orgs);
} catch (err) {
next(err);
}
}
);
router.post(
'/organizations/:id/interactions',
requireLogin,
requirePermission('EditInteraction'),
async (req, res, next) => {
try {
const { channel, subject, body, outcome, happened_at, contact_id } = req.body;
const userId = req.user.id;
const orgId = req.params.id;
const interaction = await prisma.interaction.create({
data: {
organization_id: orgId,
contact_id: contact_id || null,
user_id: userId,
channel,
subject,
body,
outcome,
happened_at: new Date(happened_at),
},
});
// Optional: audit logging
// await logAudit('create_interaction', userId, 'interaction', interaction.id, ...);
res.status(201).json(interaction);
} catch (err) {
next(err);
}
}
);
module.exports = router;
Node.js strengths for this step
- Prisma keeps schema close to the canonical SQL model.
- Great for API-first and microservice architectures, plus real-time features.
Trade-offs
- You must enforce standards (logging, tests, error handling) yourself.
- Without a structured framework (e.g., NestJS), large CRM codebases can get messy.
Drupal
For Drupal, the most natural approach is:
- Use content types for “Organization” and “Contact” with custom fields.
- Use another content type or custom entity for “Interaction”, with Entity Reference fields pointing to Organization/Contact.
- Use Drupal permissions to gate pages and lists.
Permissions
# mymodule.permissions.yml
view client:
title: 'View CRM clients'
description: 'View organizations and contacts in CRM'
edit client:
title: 'Edit CRM clients'
description: 'Create and edit organizations and contacts'
view interaction:
title: 'View interactions'
edit interaction:
title: 'Log and edit interactions'
Routing
# mymodule.routing.yml
mymodule.crm_organization_list:
path: '/crm/organizations'
defaults:
_controller: '\Drupal\mymodule\Controller\CrmController::orgList'
_title: 'Organizations'
requirements:
_permission: 'view client'
mymodule.crm_organization_view:
path: '/crm/organization/{organization}'
defaults:
_controller: '\Drupal\mymodule\Controller\CrmController::orgView'
_title: 'Organization'
requirements:
_permission: 'view client'
Controller (excerpt)
namespace Drupal\mymodule\Controller;
use Drupal\Core\Controller\ControllerBase;
use Drupal\node\NodeInterface;
class CrmController extends ControllerBase {
public function orgList() {
$storage = $this->entityTypeManager()->getStorage('node');
$nids = $storage->getQuery()
->condition('type', 'organization')
->condition('status', 1)
->range(0, 50)
->execute();
$nodes = $storage->loadMultiple($nids);
$items = [];
foreach ($nodes as $node) {
$items[] = $node->toLink()->toString();
}
return [
'#theme' => 'item_list',
'#items' => $items,
];
}
public function orgView(NodeInterface $organization) {
// You can configure a view mode that embeds a View
// listing the last 20 related interactions.
return $this->entityTypeManager()
->getViewBuilder('node')
->view($organization, 'full');
}
}
Drupal strengths for this step
- Excellent when CRM is part of a content-heavy portal.
- Views + workflows + roles allow UI-driven configuration of client lists and dashboards.
Trade-offs
- Not designed as an ERP/CRM core engine; too much business logic in custom modules can become heavy.
Quick Comparison – Where Each Framework Fits
| Framework | CRM Strengths | Use it when… |
|---|---|---|
| Laravel | Fast CRUD/admin, Blade/Livewire, Filament/Nova | Internal CRM, MVPs, full-stack PHP teams |
| Django | Admin, generic views, Python ecosystem | Portals, public sector, data-heavy internal tools |
| Spring | Enterprise IAM/SSO, policies, audit | Enterprise CRM/ERP, regulated environments |
| ASP.NET | Performance, Azure/Entra integration, MS tooling | Microsoft shops, Azure-first, Office/Power BI synergy |
| Node | Flexible API-first, microservices, real-time | API layer, microservices, full-stack JS teams |
| Drupal | Content + fine-grained editorial permissions | Content-heavy sites plus light CRM capability |
Conclusion – Which Framework Fits the Client Registry Best?
With Step 2, you’ve seen the same basic CRM module (organizations, contacts, interactions, permissions, audit) implemented across six ecosystems.
It’s natural to ask: “So which framework is best for a Client Registry?”
The honest answer: it depends on your context.
As a rough guide:
- Laravel is great when you have a PHP team, a LAMP-style environment or shared hosting, and you need to ship quickly with CRUD, dashboards, and custom back office UIs.
- Django fits well if you’re in the Python world (data, analytics, scripting) and want an internal CRM/portal where the built-in admin saves a lot of effort.
- Spring Boot is a strong choice in Java enterprise ecosystems, where you must integrate with corporate IAM, SSO (OIDC/SAML), message brokers, and compliance-heavy infrastructure.
- ASP.NET Core makes a lot of sense in Microsoft-centric environments (Entra ID, SQL Server, Azure) where CRM functionality needs to integrate cleanly with .NET, Office 365, Power BI, and internal standards.
- Node.js + Express is ideal when your main goal is a light, flexible API layer, microservices, or real-time features, and your team is full-stack JavaScript.
- Drupal is the right fit when CRM capability is a layer on top of a content-rich site, where editors and non-technical users need to manage content, customers, and interactions through the UI.
However, these guidelines alone are not enough. Choosing a framework must always be contextualized to:
- Work environment: OS, databases, existing logging/monitoring, CI/CD, container/orchestration stacks.
- Protocols and integrations: SSO (OIDC/SAML), messaging, existing APIs, VPNs, reverse proxies, API gateways, and internal security rules.
- Internal governance: approved languages/frameworks, required logging/audit standards, retention and encryption policies, data residency constraints.
- Team skills: what your team can build, debug, and maintain over years—not just what looks attractive on paper.
ArchPilot does not try to declare a universal winner.
Instead, it provides a shared vocabulary and reference: one canonical schema, one business capability, six implementations. From there, you can make an informed decision based on your actual environment, constraints, and business goals.
Disclaimers
ArchPilot is a personal, non-commercial project created for educational, demonstrative, and professional portfolio purposes. It is not affiliated with, endorsed by, or derived from any proprietary ERP/CRM vendor.
Open-source frameworks.
All frameworks referenced (Laravel, Django, Spring Boot, ASP.NET Core, Node.js/Express, Drupal) are open-source or publicly licensed. Their names and logos are trademarks of their respective owners.
Illustrative code.
Code snippets, database schemas, and configurations shown in ArchPilot are illustrative examples designed to explain architectural decisions across ecosystems. They are not production-ready and are provided “as is” without any warranty. Before using anything in a real environment, you must:
- perform full code reviews and security reviews,
- run functional and load tests,
- design and verify logging, monitoring, backup, and disaster-recovery plans.
Security & compliance.
If you implement similar concepts in a real system, you are solely responsible for compliance with applicable laws and regulations (e.g., GDPR, HIPAA, SOC 2, PCI-DSS, data residency rules, internal policies). This includes, among other things:
- secure authentication and authorization,
- proper secret management (passwords, API keys, certificates),
- rate-limiting and protection against CSRF/XSS/SQL injection and other attacks,
- encryption in transit and, when required, at rest,
- data lifecycle management (retention, anonymization, right to be forgotten).
Data and scenarios.
Any reference to clients, data, workflows, or business scenarios is fictional or anonymized. No real, confidential, or third-party proprietary data is included.
SSO and identity.
Single sign-on integrations (e.g., OIDC/SAML) require correct configuration with your identity provider. Do not hard-code secrets in source code; use secure secret-management systems (vaults, key management services, etc.).
No legal or professional advice.
ArchPilot does not provide legal, tax, security, or compliance advice. For decisions with legal or regulatory impact, consult qualified professionals (lawyers, DPOs, security consultants, auditors).
Performance & costs.
Any statements about performance, scalability, or infrastructure costs are indicative only and will vary with real workloads, architecture, hardware, cloud/provider choices, and the optimizations you implement.
By using any part of ArchPilot’s materials, you agree that you assume all risks and responsibilities for verifying, adapting, and deploying them in your own environment.
