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 |
| Realtime board channel | app/Broadcasting/LeadBoardChannel.php (registered in routes/channels.php) |
| Realtime board broadcasts | app/Events/Lead/Lead{Created,Updated,StageChanged,Assigned,Deleted}Broadcast.php + LeadBoardBroadcastPayload.php |
| Subscriber | app/Listeners/LeadEventSubscriber.php (audit + notifications + realtime board broadcasts) |
| 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, LeadRealtimeBoardTest.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”. - List/board/export accept
follow_up=today|overdueto return leads that have a matching pending follow-up (whereHasonfollowUps, same bounds as stats). Stats strip ignoresfollow_upso KPI totals stay stable while the list is filtered. SPA KPI cards toggle this filter. Catalog 1.9.1. 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(includescompany_id/company_uuid/company_nameand related contact/opportunity names when set), statusclosed, activity type converted. Optional convert bodycompany_id/company_name(in addition to leadcompanystring). 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, resolves company via explicitcompany_id, thencompany_name/ leadcompany, creates or reuses by case-insensitive name (conversion_meta.company_*, Contactcompany_id), records company activityConvertedFromLead, 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. Workspace settings gate convert:leads.convert_require_opportunity(boolean, defaultfalse) requirescreate_opportunitywhen Opportunities is entitled;leads.convert_min_opportunity_amount(numeric, default0) rejects create when effective amount (opportunity.amount??lead.lead_value??0) is below the floor. Enabling require without Opportunities entitlement returns 422 on settings update. FiresLeadConverted; Automation triggerlead.convertedis wired (wired=true). Stub converts (nocontact_id) can be completed by calling convert again after Contacts is installed. 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.7.0. - Created by / Updated by: nullable
leads.created_by/updated_by(FK → users). Set onLeadService::create(both) and on update/convert/restore/delete plus notes, tag sync, and follow-up create/update/complete (updated_by). Migration backfills in batches fromlead_activities(type=created→ created_by; else earliest actor; latest actor → updated_by). List/board/stats/export acceptcreated_by/updated_byquery params (user id orsystemfor null).ListLeadResourceembedscreator/updatersummaries. Production readiness: Leads 1.6.0 created/updated by.
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 digests: Workspace settings (group
leads):leads.inactivity_working_days(integer, default3;0disables),leads.inactivity_active_only(boolean, defaulttrue),leads.inactivity_skip_pending_follow_up(boolean, defaulttrue),leads.inactivity_manager_email(boolean, defaulttrue),leads.inactivity_assignee_notify(boolean, defaulttrue),leads.inactivity_reminder_working_days(integer, default7;0= first threshold crossing only,1= daily while idle, otherwise one digest per recipient per N working days). 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,whatsapp_message_sent,whatsapp_message_received. Excluded from resetting idle:assigned,reassigned,imported,created. Sends one assignee digest (lead.inactive.digest, in-app + push) and one manager/owner digest (lead.inactive_escalation.digest, in-app + push + optional table email with clickable name/phone/email). Leads stay assigned. Idempotent viaNotificationIdempotency(period dedupe per recipient). PestLeadInactiveNotificationTest. - Convert opportunity gates:
leads.convert_require_opportunity(boolean, defaultfalse) andleads.convert_min_opportunity_amount(numeric string, default0) under groupleads. Resolved viaTenantSettingService::leadsConvertRequireOpportunity()/leadsConvertMinOpportunityAmount(). Enforced inLeadService::convert. Pest:LeadConvertOpportunitySettingsTest.
Real-time board sync
Mirrors the Live Chat inbox invalidate pattern (LiveChatInboxChannel / useLiveChatRealtime) — not the Team Chat message-patching pattern. The board refetches on change instead of patching individual cards.
- Channel: private
tenant.{tenantId}.leads.board, registered inroutes/channels.php. Auth (LeadBoardChannel::join): same tenant andleads.view— same shape asLiveChatInboxChannel. - Broadcast events (
App\Events\Lead\*, allShouldBroadcastNow, kept separate from the existing domain events of the same base name):LeadCreatedBroadcast→broadcastAs('LeadCreated')LeadUpdatedBroadcast→broadcastAs('LeadUpdated')LeadStageChangedBroadcast→broadcastAs('LeadStageChanged')(includesprevious_stage_id)LeadAssignedBroadcast→broadcastAs('LeadAssigned')(includesassigned_to)LeadDeletedBroadcast→broadcastAs('LeadDeleted')
- Payload shape (
LeadBoardBroadcastPayload, shared across all five events):action,lead_id,uuid,actor_id,stage_id, plusprevious_stage_id(stage-changed only) /assigned_to(assigned only). - Dispatch:
LeadEventSubscriberfires the matching broadcast fromhandleLeadCreated/handleLeadUpdated/handleLeadStageChanged/handleLeadAssigned/handleLeadDeleted, wrapped in asafeBroadcast()try/catch (mirrorsLiveChatConversationService::safeBroadcast) so a Reverb outage never breaks the write path — onlyreport()s the exception. - Frontend:
src/hooks/use-leads-board-realtime.tsjoins the channel via the sharedwindow.Echoinstance (never connects/disconnects Echo itself) whileleads-page.tsxhasviewMode === 'board'. Listens for.LeadCreated/.LeadUpdated/.LeadStageChanged/.LeadAssigned/.LeadDeletedand debounces (~200ms) an invalidate ofQUERY_KEYS.leadBoard+QUERY_KEYS.leadStatsso a burst of events (e.g. a bulk stage move) collapses into a single refetch — no per-card patching. - Catalog leads 1.7.0 → 1.8.0 (migrate-only
DefaultModuleRegistrar::bumpVersion+CatalogSeedercompanion). - No Reverb broadcaster is spun up in CI; Pest verifies dispatch via
Illuminate\Support\Facades\Event::fake([...Broadcast::class])+Event::assertDispatched(...), the same approachLiveChatModuleTestuses (Broadcast::fake()is not available on this Laravel version'sBroadcastfacade). Production readiness: Leads real-time board sync 1.8.0.
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) |
| Realtime board hook | src/hooks/use-leads-board-realtime.ts — joins tenant.{id}.leads.board, debounce-invalidates board + stats |
| Service | leadService in src/api/services.ts |
| Nav | permission: leads.view, module: 'leads' |
| Catalog | 1.8.0 (real-time board sync; 1.7.0 convert require opportunity + min amount settings) |
Tests
# Backend
php artisan test --compact tests/Feature/Tenant/Lead
php artisan test --compact tests/Feature/Tenant/Lead/LeadImportTest.php
php artisan test --compact tests/Feature/Tenant/Lead/LeadRealtimeBoardTest.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.