Skip to main content

CRM quickstart

A pragmatic guide for picking up a CRM ticket on day one. Pairs with architecture and api.

Local environment

# Backend
cd tenant/tenant-backend-crm
cp .env.example .env
composer install
php artisan migrate --seed
php artisan crm:consume-identity-events & # background worker for RabbitMQ
php artisan serve --port=8012

# Frontend (CRM is part of the shared tenant-frontend SPA)
cd tenant/tenant-frontend
cp .env.example .env
npm install
npm run dev

Per-tenant env vars you'll need:

TENANT_ID=acme-uuid
TENANT_NAME=acme
SERVICE_NAME=crm
IDENTITY_URL=http://localhost:8000
OAUTH_CLIENT_ID=...
OAUTH_CLIENT_SECRET=...
SYSTEM_API_TOKEN=... # for SmsSystem + admin routes

The full env list is in environment-reference.md.

Make targets (root Makefile)

make dev-crm                # boot CRM + dependencies
make fresh # wipe + reseed identity (for OAuth dev flows)

make dev-crm boots Postgres + Redis + RabbitMQ + the CRM PHP server

  • the identity-event consumer worker.

First task: add a new endpoint

Example: add GET /api/crm/contacts/{id}/duplicates that returns possible duplicate contacts by name/email.

1. Route + permission

// routes/api.php
Route::get('/contacts/{id}/duplicates', [ContactController::class, 'duplicates'])
->middleware('can:contacts.list');

contacts.list is the canonical permission for read-only access. crm:list-permissions will pick the new entry up; CI's api:check will catch any drift.

2. Controller method

public function duplicates(Request $request, string $id): AnonymousResourceCollection|JsonResponse
{
$contact = Contact::query()->find($id);
if (! $contact) {
return response()->json(['message' => 'Contact not found.'], 404);
}

$duplicates = Contact::query()
->where('id', '!=', $contact->id)
->where(function ($q) use ($contact) {
if ($contact->email) {
$q->where('email', $contact->email);
}
$q->orWhere(function ($name) use ($contact) {
$name->where('first_name', $contact->first_name)
->where('last_name', $contact->last_name);
});
})
->limit(20)
->get();

return ContactResource::collection($duplicates);
}

Skip the FormRequest validator if the input is just a path UUID. Use StoreContactRequest for POST/PUT bodies — the audit findings on input hardening live in those classes.

3. OpenAPI regen + SDK

cd tenant/tenant-frontend
npm run api:sync # curl swagger/*.json + openapi-ts regen

The CI check (api:check) verifies the diff was committed.

4. Frontend call site

// modules/crm/stores/contact.ts
async function fetchDuplicates(id: string) {
loading.value = true
try {
const { data } = await crmClient.get({ url: `/contacts/${id}/duplicates` })
return (data as any).data ?? []
} catch (err: any) {
notifyError(err, 'crm.contacts.notifications.duplicatesFailed')
throw err
} finally {
loading.value = false
}
}

useApiError already provides notifyError for the snackbar.

5. Tests

// tests/Feature/ContactDuplicatesTest.php
public function test_duplicates_endpoint_returns_email_matches(): void
{
$primary = Contact::factory()->create(['email' => 'shared@x.com']);
$dupe = Contact::factory()->create(['email' => 'shared@x.com']);

$this->withToken($this->makeToken())
->getJson("/api/crm/contacts/{$primary->id}/duplicates")
->assertOk()
->assertJsonFragment(['id' => $dupe->id]);
}

Common pitfalls

SymptomLikely causeFix
cURL error 7: localhost:8080 on dashboardDevOps API unreachableDEVOPS_API_MOCK=true in main-backend/.env
503 identity_unavailable on every requestIdentity service downstart it; CRM will recover in ~60s (user cache TTL)
403 tenant_not_a_memberToken's tenants[] does not include this CRM's tenantre-mint token; legacy single-tenant_id tokens stop working post 2026-06-15
token_shape_legacyToken predates F-future-6 + cutoff hitre-authenticate
permission_denied with empty policiesIdentity policy fetch failedcheck identity logs; CheckPermission already 503s on transient failure
File upload 403 File path rejected.Old DB row with .. in pathclean up the metadata column
version_chain_size_exceeded (422)Document chain hit 1 GiBdelete old versions

Code conventions

  • Controllers stay thin. Validation + delegation + Resource response. Anything beyond ~30 lines per method is a candidate for a service or a controller split (see controller-split).
  • Models own state machines. LeaveRequest::TRANSITIONS, TaskHistory::ALLOWED_ACTIONS, CommunicationLog::ALLOWED_RELATED_TYPES, ProjectMember::ALLOWED_ROLES. New states/types land here.
  • Stores use useApiError. Never inline notifyError/notifySuccess — the composable is the source of truth.
  • List pages use usePaginationDeepLink + useDeleteDialog. companies/contacts/projects are the reference.
  • Search uses App\Support\Search. Drop-in for ilike LIKE with Postgres unaccent. The 5 existing scopeSearch methods are the pattern.
  • Audit events go to ActivityLog. Wrap properties with $this->auditActor($user) for the impersonator field.

Where things break first

When something feels off, check these in order:

  1. php artisan crm:list-permissions — does the route's permission exist?
  2. Identity logs — is getUserPolicies returning a non-empty array?
  3. tenant-backend-crm/storage/logs/laravel.log — application-level errors
  4. RabbitMQ management UI — is crm.identity-events.{tenant} queue draining?
  5. tenant-backend-crm/AUDIT_FINDINGS.md — has the case already been addressed?

Where to learn more

  • architecture.md — diagrams + layer responsibilities
  • api.md — resource map + error shape
  • security.md — 5-layer model + audit cross-refs
  • events.md — identity ↔ CRM RabbitMQ contract
  • multi-tenant.md — deployment-per-tenant model
  • controller-split.md — F3 ChatController split, pattern for further extracts
  • tenant/tenant-backend-crm/AUDIT_FINDINGS.md — full audit log with per-batch close notes