Leads — Developer Guide
Reference implementation. Copy this layout for Tasks and later modules.
Backend layout
| Piece | Path |
|---|---|
| Models | app/Models/Lead.php, LeadStage, LeadTag, LeadNote, LeadNoteMention, LeadFollowUp, LeadActivity, LeadAssignmentHistory |
| Enums | app/Enums/Tenant/LeadStatusEnum, LeadPriorityEnum, LeadTypeEnum, LeadFollowUpStatusEnum, LeadActivityTypeEnum, LeadTagBehaviorEnum |
| Service | app/Services/Tenant/LeadService.php (+ ScopesToAssignee), LeadTagService.php |
| Export | app/Exports/LeadsExport.php |
| Import framework | app/Import/* (ImportManager, ImportFile, ImportColumnMapper, ImportErrorWriter, ImportTemplateGenerator, ImportHistory, ImportJob) |
| Lead import handler | app/Import/Lead/LeadImportHandler.php, LeadImportValidator, LeadImportMapper |
| Import model | app/Models/LeadImport.php (lead_imports table) |
| Import job | app/Jobs/ProcessLeadImportJob.php → queue imports |
| Controllers | LeadController.php, LeadImportController.php |
| Requests | app/Http/Requests/Tenant/Api/V1/Lead/* |
| Resources | app/Http/Resources/Tenant/Api/V1/Lead/* |
| Policy | app/Policies/LeadPolicy.php |
| Events | app/Events/Lead*.php |
| Subscriber | app/Listeners/LeadEventSubscriber.php (audit + notifications) |
| Notifications | app/Notifications/Tenant/Lead/* (assign: database+broadcast+webpush; follow-ups/mentions: database + optional mail; mentions also broadcast+webpush; inactivity: database+broadcast+webpush) |
| Inactivity job | app/Services/Tenant/LeadInactivityService.php, app/Console/Commands/NotifyInactiveLeadsCommand.php (leads:notify-inactive, daily) |
| Mentions | App\Support\NoteMentions, NoteMentionService; wired from LeadNoteAdded in LeadEventSubscriber |
| Seeder | database/seeders/Tenant/LeadStageSeeder.php, LeadTagSeeder.php |
| Tests | tests/Feature/Tenant/Lead/LeadTest.php, LeadTagTest.php, LeadTypeTest.php, LeadValidationTest.php, LeadImportTest.php, LeadSameDayDuplicateTest.php, tests/Feature/Tenant/Notification/NoteMentionNotificationTest.php, tests/Unit/NoteMentionsTest.php |
Domain notes
- Disposition tags are many-to-many (
lead_lead_tag), independent of stage/status. Catalog CRUD + reorder underLeadTagController. New leads receiveis_defaulttags. Sync viaPUT /leads/{id}/tags(and optionaltag_idson create).auto_follow_upcreates a pending follow-up keyed bylead_follow_ups.lead_tag_id;force_follow_uprequires a nestedfollow_uppayload. System tagduplicateis seeded (protected from delete) and applied when email/phone matches another lead created the same workspace calendar day — manual create, import (Keep/Skip; Update only when a distinct same-day match remains after excluding the row being updated), and inbound ingest still notify assignee/creator/actor vialead.duplicate_detected. - Lead type (
direct|company) is stored onleads.lead_type(required on create;sometimes|requiredon update so partial PUTs may omit it; nullable for legacy rows). System tagsdirect-lead/company-leadare seeded inLeadTagSeederand kept mutually exclusive viaLeadTagService::mergeExclusiveTypeTags()on create, update, and manual tag sync. - Note bodies may include
@[Display Name](user:ID)mention tokens (composer UI shows@Namechips). OnLeadNoteAdded,NoteMentionServicepersistslead_note_mentionsand sendslead.mentioned(skip self; idempotent viadedupe_key). Mail is optional viaemail_notifications.lead_mentioned(default off). - Follow-up
due_atfollows the Workspace timezone convention: SPA edit/display in Settings → General timezone (isoToAppLocalInput/appLocalInputToIso); store as UTC viaUtcDateTime/UtcIso; overdue / due-today stats and widgets compare withUtcInstant; due/overdue notifications use workspace-local “today”. lead_valuereplacedestimated_value(migration rename). Store/update requests still acceptestimated_valueas a write alias.- Status is independent of stage flags (
is_won/is_lost). Stage change does not sync status. - Convert:
converted_at,conversion_meta, statusclosed, activity type converted. When Contacts is entitled, creates/links a realContact(contact_id,conversion_meta.stub = false, lifecycleon_boarded) and requirescontacts.create; preserves the lead assignee; runs in a DB transaction. When Companies is entitled and the lead has a non-emptycompanystring, creates or reuses a Company by case-insensitive name (conversion_meta.company_id, Contactcompany_id) and requirescompanies.createonly when creating. Company create is serialized with a per-tenant+name cache lock to reduce duplicates; a residual race without a unique DB name constraint remains (known limitation). Staleconversion_meta.company_idvalues are cleared when the company cannot be resolved. When Opportunities is entitled and the request setscreate_opportunity: truewithopportunity.name, creates an Opportunity linked to the lead / contact / company (conversion_meta.opportunity_id) and requiresopportunities.create. Stub converts (nocontact_id) can be completed by calling convert again after Contacts is installed (company/opportunity already inconversion_metaare reused and linked; linking an existing opportunity requiresopportunities.update). Without Contacts, conversion remains status-only for the contact (conversion_meta.stub = true) but may still create company/opportunity.LeadPolicy::convertuses the same assignee scope as update. Catalog version 1.1.1.
Known limitations
- Company name uniqueness on convert: Convert reuses companies by case-insensitive name and takes a short cache lock around create, but company names are not unique in the schema. Two concurrent converts of different leads with the same brand-new company name can still insert duplicates if the lock backend cannot coordinate (or races past the lock window). Operators should merge duplicates manually if that occurs; a future unique index would fully close this.- Assignee scoping via
ScopesToAssigneewithleads.assign(superadmin always org-wide). - Create default assignee: when
assigned_tois omitted (or stripped because the actor lacksleads.assign),LeadService::createsetsassigned_toto the actor — same pattern as Contacts/Opportunities/Projects. Inbound ingest (LeadIngestionService) passes no actor, so webhook/Meta leads stay unassigned until optional website-recipient distribute or explicit mapping. - Lead assignee eligibility (
User::eligibleLeadAssignees/EligibleLeadAssigneerule): excludes suspended users, workspace owners (superadmin), and users withexclude_from_lead_auto_assign. Used by assign / create / update / bulk-assign / import column mapping, and byLeadBulkAssignmentService::eligibleAssigneesfor equal distribute. - Website webhook auto-assign uses
eligibleLeadAssignees ∩ receive_website_leadswhen the endpoint flagassign_to_website_recipientsis enabled. Meta Lead Ads does not use this pool. - Import equal distribute (
assignment_mode=equal): requiresleads.assignand that the importer ismanager_idon ≥1 active department. Pool = eligible assignees ∩ members of those departments (assignEquallyForImport). Non-managers receive a validation error. Bulk equal distribute remains org-wide eligible assignees. - Commission rate:
users.lead_commission_rate(nullable decimal 0–100) is the user’s default.LeadService::create(whenassigned_tois set) andLeadService::update/assigncopy the assignee’s rate toleads.commission_rateon assign/reassign and clear it on unassign. Snapshot is reporting-only (export, list, detail) — no payout engine. Bulk assign and import equal distribute useassign()so snapshots apply there too. - Inactivity alerts: Workspace setting
leads.inactivity_working_days(integer, default3;0disables). Scheduled commandleads:notify-inactiveruns daily per tenant. Counts Mon–Sat working days in the workspace timezone (Sundays excluded). Idle = assigned lead in an open stage (not Won/Lost) with no meaningfullead_activitiessince the last assignment baseline. Meaningful types:note_added,follow_up_created,follow_up_completed,stage_changed,status_changed,crm_activity_logged,crm_activity_completed,tags_changed. Excluded from resetting idle:assigned,reassigned,imported,created. Notifies assignee (lead.inactive) plus department managers of the assignee (lead.inactive_escalation), else workspace owners. Idempotent viaNotificationIdempotency(daily dedupe per lead/recipient).
Permissions
config/tenant-permissions.php:
leads.view | create | update | delete | assign | export | import | convertRoutes use module:leads then can:leads.* / policies.
Import architecture
- Reusable package: Maatwebsite Laravel Excel for CSV/XLSX read + templates
- Entity-agnostic
app/Importframework; Lead is the first handler (future modules add their own handlers) LeadImportMappercasts spreadsheet string fields (phone,name,email,note, etc.) to strings — Excel often returns phone as int/float- Every imported lead row uses
LeadService::create()/update()— never bypasses business rules - Optional mapped Note column calls
LeadService::addNote()after create (and after update when the cell is non-empty; empty cells are skipped) - All runs are async:
ProcessLeadImportJob::dispatch(...)->onQueue('imports') - Uploads stored on the configured uploads disk under
imports/{tenant_uuid}/ - Single table
lead_importsholds file metadata, mapping, options, status, stats, and report paths - Platform audit:
lead_import_completed/lead_import_failed
Inbound ingestion (shipped): Custom Webhook + Meta Lead Ads use the Lead Source Driver Architecture. See Custom Lead Webhook, Meta App Setup, and Meta Lead Ads. Drivers normalize only; LeadDuplicateService + LeadService remain the sole write path.
API (tenant)
Base: /api/tenant/v1 — full reference tenant-v1-leads.md.
| Method | Path | Permission |
|---|---|---|
| GET | /lead-stages | view |
| GET | /leads | view |
| GET | /leads/stats | view |
| GET | /leads/board | view |
| GET | /leads/export | export |
| GET | /leads/import/template | import |
| GET/POST | /leads/imports | import |
| GET/PUT | /leads/imports/{import} | import |
| PUT | /leads/imports/{import}/options | import |
| POST | /leads/imports/{import}/preview | import |
| POST | /leads/imports/{import}/run | import |
| GET | /leads/imports/{import}/file | import |
| GET | /leads/imports/{import}/failed-records | import |
| GET | /leads/imports/{import}/error-report | import |
| POST | /leads | create |
| GET | /leads/{lead} | view |
| PUT | /leads/{lead} | update |
| DELETE | /leads/{lead} | delete |
| POST | /leads/{lead}/assign | assign |
| POST | /leads/{lead}/convert | convert |
| POST | /leads/{lead}/stage | update |
| POST | /leads/{lead}/notes | update |
| POST | /leads/{lead}/follow-ups | update |
| PUT | /leads/{lead}/follow-ups/{followUp} | update |
| POST | /leads/{lead}/follow-ups/{followUp}/complete | update |
| GET | /leads/{lead}/timeline | view |
| GET | /leads/{lead}/assignment-history | view |
Auth login/me include modules: string[] for SPA gating.
Frontend
| Piece | Path |
|---|---|
| Page | src/pages/leads/leads-page.tsx (board default + table; wires list sheet) |
| List sheet | lead-record-sheet.tsx + shared EntityRecordSheet — New / n / row peek; dedicated routes remain for deep links |
| Form | lead-form.tsx + lead-form-page.tsx (/leads/new, /leads/:id/edit; embed mode for sheet) |
| Detail | lead-view-page.tsx (/leads/:id) — stacked cards; board DnD auto-saves stage on the list page |
| Import wizard | lead-import-dialog.tsx (5-step) |
| Import history | lead-import-history-dialog.tsx |
| Shared board | src/components/crm/kanban-board.tsx (per-column vertical scroll + contained horizontal scroll; titles stay fixed; touch pan on column lists) |
| Mentions UI | src/components/crm/mention-composer.tsx (shows @Name chips; emits @[Name](user:id); keep typing after pick; Backspace/Delete removes chips), src/lib/note-mentions.ts (formatNoteMentionsForDisplay in record pages + latest-note-follow-up.tsx list/board previews) |
| Notification registry | src/notifications/modules/crm.ts (lead.mentioned, lead.duplicate_detected, lead.inactive, lead.inactive_escalation) |
| Service | leadService in src/api/services.ts |
| Nav | permission: leads.view, module: 'leads' |
| Catalog | 1.4.0 (hybrid list sheets) |
Tests
# Backend
php artisan test --compact tests/Feature/Tenant/Lead
php artisan test --compact tests/Feature/Tenant/Lead/LeadImportTest.php
# Worker (import jobs)
php artisan queue:work --queue=imports,default
# Frontend E2E
npm run test:e2e:leadsLogging
- Spatie
LogsActivityonLead(log nameleads) - Domain
lead_activitiestimeline lead_assignment_historiesfor assignee changesusers.lead_commission_rate,leads.commission_rate— snapshot on assign viaLeadService::assignPlatformAuditServiceviaLeadEventSubscriber(+lead_import_completed/lead_import_failed)
Ask EloSync
Ask EloSync Lead tools (existing reads + update_lead_status, plus confirmed assign_lead / add_lead_note) are registered in AIToolRegistry and confirmed via PendingAiActionService. Assign uses EligibleLeadAssignee at propose and confirm. Get/search payloads include assigned_to (user id) and assignee_name. See AI tools and AI Lead assign + note production readiness.