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
| Symptom | Likely cause | Fix |
|---|---|---|
cURL error 7: localhost:8080 on dashboard | DevOps API unreachable | DEVOPS_API_MOCK=true in main-backend/.env |
503 identity_unavailable on every request | Identity service down | start it; CRM will recover in ~60s (user cache TTL) |
403 tenant_not_a_member | Token's tenants[] does not include this CRM's tenant | re-mint token; legacy single-tenant_id tokens stop working post 2026-06-15 |
token_shape_legacy | Token predates F-future-6 + cutoff hit | re-authenticate |
permission_denied with empty policies | Identity policy fetch failed | check identity logs; CheckPermission already 503s on transient failure |
File upload 403 File path rejected. | Old DB row with .. in path | clean up the metadata column |
version_chain_size_exceeded (422) | Document chain hit 1 GiB | delete 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 inlinenotifyError/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 forilikeLIKE with Postgresunaccent. The 5 existingscopeSearchmethods are the pattern. - Audit events go to
ActivityLog. Wrappropertieswith$this->auditActor($user)for the impersonator field.
Where things break first
When something feels off, check these in order:
php artisan crm:list-permissions— does the route's permission exist?- Identity logs — is
getUserPoliciesreturning a non-empty array? tenant-backend-crm/storage/logs/laravel.log— application-level errors- RabbitMQ management UI — is
crm.identity-events.{tenant}queue draining? 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