Contacts — Developer Guide
Mirror of the Leads developer guide / Tasks developer guide. Prefer copying those patterns over inventing new ones.
Backend layout
| Piece | Path |
|---|---|
| Models | app/Models/Contact.php, ContactNote, ContactActivity |
| Enum | app/Enums/Tenant/ContactActivityTypeEnum, ContactLifecycleStatusEnum |
| Service | app/Services/Tenant/ContactService.php (+ ScopesToAssignee) |
| Controller | app/Http/Controllers/Tenant/Api/V1/ContactController.php |
| Requests | app/Http/Requests/Tenant/Api/V1/Contact/* |
| Resources | app/Http/Resources/Tenant/Api/V1/Contact/* |
| Policy | app/Policies/ContactPolicy.php |
| Events | app/Events/Contact*.php |
| Subscriber | app/Listeners/ContactEventSubscriber.php (audit + assignment notification) |
| Notifications | app/Notifications/Tenant/Contact/ContactAssignedNotification.php |
| Placeholders | app/Services/Tenant/CommunicationTemplates/Providers/ContactPlaceholderProvider.php |
| Tests | tests/Feature/Tenant/Contact/ContactTest.php |
Domain notes
lifecycle_status(on_boarded|off_boarded) is independent of soft-delete (deleted_at/trashedfilters). SPA labels use On Boarded Clients / Off Boarded Clients.- Assignee scoping via
ScopesToAssigneewithcontacts.assign; without it, users only see contacts assigned to them (view/update/list/stats). contacts.force.deleteis not granted to any default role — owner/superadmin only, matching Leads/Tasks.- Lead → Contact linkage:
leads.contact_id(nullable FK).LeadService::convert()creates (or reuses) a Contact when thecontactsmodule is entitled (requirescontacts.create, preserves lead assignee, sets lifecycleon_boarded, transactional). When Companies is entitled and the lead has a company name, also creates/links a Company onto the Contact. Optional Opportunity creation uses the same convert endpoint (create_opportunity). Stub converts withoutcontact_idcan be completed after Contacts is installed. Otherwise conversion remains the earlier status-only placeholder for contacts (conversion_meta.stub = true). - Contact → Company linkage:
contacts.company_id(nullable FK) when Companies is entitled. Writes sync the legacycompanystring from the linked Company name. Resources exposelinked_companywhen loaded. - SPA: Contact create/edit can open
create-company-dialog.tsx(companies.create) and auto-select the newcompany_idwithout navigating to Companies. - Sales prefill: Quotation / invoice / payment create forms accept
?contact=/?company=on create only (src/lib/related-record-query.ts). - Party billing hub:
CustomerPartyBillingPanelon contact view — summary strip + recent invoices/payments/credit notes + statement route. Backend:CustomerPartyBillingSummaryService,CustomerAccountStatementService(+ PDF). List deep links?contact=/?company=on invoices, payments, quotations, credit notes. - Statement JSON includes
opening_balance(pre-from) andbalance_due(as ofto). Credits on statements are applied only. - Assignee eligibility mirrors Leads (
EligibleContactAssignee/User::isEligibleLeadAssignee).
Permissions
config/tenant-permissions.php:
contacts.view | create | update | delete | restore | force.delete | assignRoutes use module:contacts then can:contacts.* / policies.
API (tenant)
Base: /api/tenant/v1 — full reference tenant-v1-contacts.md.
| Method | Path | Permission |
|---|---|---|
| GET | /contacts | view |
| GET | /contacts/stats | view |
| GET | /contacts/{contact} | view |
| GET | /contacts/{contact}/timeline | view |
| POST | /contacts | create |
| PUT | /contacts/{contact} | update |
| DELETE | /contacts/{contact} | delete |
| POST | /contacts/{contact}/restore | restore |
| DELETE | /contacts/{contact}/force | force.delete |
| POST | /contacts/{contact}/assign | assign |
| POST | /contacts/{contact}/notes | update |
| GET | /contacts/{contact}/billing-summary | view |
| GET | /contacts/{contact}/statement | view |
| GET | /contacts/{contact}/statement.pdf | view |
Auth login/me include modules: string[] for SPA gating.
Frontend
| Piece | Path |
|---|---|
| Page | src/pages/contacts/contacts-page.tsx (table + filters + KPIs) |
| Form | contact-form.tsx (+ create-company-dialog.tsx for inline company create) |
| Detail | contact-view-page.tsx (details, notes, activity; billing hub; related sales create actions) |
| Statement | src/pages/crm/party-statement-page.tsx (ContactStatementPage) |
| Service | contactService in src/api/services.ts (billingSummary, statement, downloadStatementPdf) |
| Nav | permission: contacts.view, module: 'contacts' (between Leads and Tasks) |
| Dashboard | RecentContactsWidget (recent_contacts widget) + create_contact quick action in tenant-dashboard-widgets.tsx / tenant-dashboard-page.tsx |
| Lead link | Lead record shows a View contact link when a converted lead has contact_id |
| Company link | Contact form company picker when module:companies + companies.view; New when companies.create; list/detail prefer linked_company?.name over legacy company |
| Sales prefill | Quotation / invoice / payment create forms accept ?contact= / ?company= |
| Party billing | Hub + deep links + statement; Pest tests/Feature/Tenant/PartyBilling/; Playwright e2e/tests/contacts/contacts.party-billing.spec.ts |
Tests
bash
# Backend
php artisan test --compact tests/Feature/Tenant/Contact/ContactTest.php
php artisan test --compact tests/Feature/Tenant/PartyBilling/PartyBillingSummaryAndStatementTest.php
# Frontend
npm run typecheck && npm run lint && npm run build
npm run test:e2e:contacts| Suite | Location |
|---|---|
| Pest | tests/Feature/Tenant/Contact/ContactTest.php (+ Lead convert cases); PartyBilling suite |
| E2E | e2e/tests/contacts/, including contacts.party-billing.spec.ts |
Logging
- Spatie
LogsActivityonContact(log namecontacts) - Domain
contact_activitiestimeline PlatformAuditServiceviaContactEventSubscriber
Intentional differences from Leads / Tasks
| Leads / Tasks | Contacts |
|---|---|
| Stages / status workflow | No workflow — directory record |
| Follow-ups | None |
| Board view | List/table only |
export / convert / complete | None (Contacts is the conversion target, not source) |