Skip to content

Leads — Developer Guide ​

Reference implementation. Copy this layout for Tasks and later modules.

Backend layout ​

PiecePath
Modelsapp/Models/Lead.php, LeadStage, LeadTag, LeadNote, LeadNoteMention, LeadFollowUp, LeadActivity, LeadAssignmentHistory
Enumsapp/Enums/Tenant/LeadStatusEnum, LeadPriorityEnum, LeadTypeEnum, LeadFollowUpStatusEnum, LeadActivityTypeEnum, LeadTagBehaviorEnum
Serviceapp/Services/Tenant/LeadService.php (+ ScopesToAssignee), LeadTagService.php
Exportapp/Exports/LeadsExport.php
Import frameworkapp/Import/* (ImportManager, ImportFile, ImportColumnMapper, ImportErrorWriter, ImportTemplateGenerator, ImportHistory, ImportJob)
Lead import handlerapp/Import/Lead/LeadImportHandler.php, LeadImportValidator, LeadImportMapper
Import modelapp/Models/LeadImport.php (lead_imports table)
Import jobapp/Jobs/ProcessLeadImportJob.php → queue imports
ControllersLeadController.php, LeadImportController.php
Requestsapp/Http/Requests/Tenant/Api/V1/Lead/*
Resourcesapp/Http/Resources/Tenant/Api/V1/Lead/*
Policyapp/Policies/LeadPolicy.php
Eventsapp/Events/Lead*.php
Realtime board channelapp/Broadcasting/LeadBoardChannel.php (registered in routes/channels.php)
Realtime board broadcastsapp/Events/Lead/Lead{Created,Updated,StageChanged,Assigned,Deleted}Broadcast.php + LeadBoardBroadcastPayload.php
Subscriberapp/Listeners/LeadEventSubscriber.php (audit + notifications + realtime board broadcasts)
Notificationsapp/Notifications/Tenant/Lead/* (assign: database+broadcast+webpush; follow-ups/mentions: database + optional mail; mentions also broadcast+webpush; inactivity: database+broadcast+webpush)
Inactivity jobapp/Services/Tenant/LeadInactivityService.php, app/Console/Commands/NotifyInactiveLeadsCommand.php (leads:notify-inactive, daily)
MentionsApp\Support\NoteMentions, NoteMentionService; wired from LeadNoteAdded in LeadEventSubscriber
Seederdatabase/seeders/Tenant/LeadStageSeeder.php, LeadTagSeeder.php
Teststests/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 under LeadTagController. New leads receive is_default tags. Sync via PUT /leads/{id}/tags (and optional tag_ids on create). auto_follow_up creates a pending follow-up keyed by lead_follow_ups.lead_tag_id; force_follow_up requires a nested follow_up payload. System tag duplicate is 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 via lead.duplicate_detected.
  • Lead type (direct | company) is stored on leads.lead_type (required on create; sometimes|required on update so partial PUTs may omit it; nullable for legacy rows). System tags direct-lead / company-lead are seeded in LeadTagSeeder and kept mutually exclusive via LeadTagService::mergeExclusiveTypeTags() on create, update, and manual tag sync.
  • Note bodies may include @[Display Name](user:ID) mention tokens (composer UI shows @Name chips). On LeadNoteAdded, NoteMentionService persists lead_note_mentions and sends lead.mentioned (skip self; idempotent via dedupe_key). Mail is optional via email_notifications.lead_mentioned (default off).
  • Follow-up due_at follows the Workspace timezone convention: SPA edit/display in Settings → General timezone (isoToAppLocalInput / appLocalInputToIso); store as UTC via UtcDateTime / UtcIso; overdue / due-today stats and widgets compare with UtcInstant; due/overdue notifications use workspace-local “today”.
  • List/board/export accept follow_up=today|overdue to return leads that have a matching pending follow-up (whereHas on followUps, same bounds as stats). Stats strip ignores follow_up so KPI totals stay stable while the list is filtered. SPA KPI cards toggle this filter. Catalog 1.9.1.
  • lead_value replaced estimated_value (migration rename). Store/update requests still accept estimated_value as a write alias.
  • Status is independent of stage flags (is_won / is_lost). Stage change does not sync status.
  • Convert: converted_at, conversion_meta (includes company_id / company_uuid / company_name and related contact/opportunity names when set), status closed, activity type converted. Optional convert body company_id / company_name (in addition to lead company string). When Contacts is entitled, creates/links a real Contact (contact_id, conversion_meta.stub = false, lifecycle on_boarded) and requires contacts.create; preserves the lead assignee; runs in a DB transaction. When Companies is entitled, resolves company via explicit company_id, then company_name / lead company, creates or reuses by case-insensitive name (conversion_meta.company_*, Contact company_id), records company activity ConvertedFromLead, and requires companies.create only 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). Stale conversion_meta.company_id values are cleared when the company cannot be resolved. When Opportunities is entitled and the request sets create_opportunity: true with opportunity.name, creates an Opportunity linked to the lead / contact / company (conversion_meta.opportunity_id) and requires opportunities.create. Workspace settings gate convert: leads.convert_require_opportunity (boolean, default false) requires create_opportunity when Opportunities is entitled; leads.convert_min_opportunity_amount (numeric, default 0) 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. Fires LeadConverted; Automation trigger lead.converted is wired (wired=true). Stub converts (no contact_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::convert uses the same assignee scope as update. Catalog version 1.7.0.
  • Created by / Updated by: nullable leads.created_by / updated_by (FK → users). Set on LeadService::create (both) and on update/convert/restore/delete plus notes, tag sync, and follow-up create/update/complete (updated_by). Migration backfills in batches from lead_activities (type=created → created_by; else earliest actor; latest actor → updated_by). List/board/stats/export accept created_by / updated_by query params (user id or system for null). ListLeadResource embeds creator / updater summaries. 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 ScopesToAssignee with leads.assign (superadmin always org-wide).
  • Create default assignee: when assigned_to is omitted (or stripped because the actor lacks leads.assign), LeadService::create sets assigned_to to 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 / EligibleLeadAssignee rule): excludes suspended users, workspace owners (superadmin), and users with exclude_from_lead_auto_assign. Used by assign / create / update / bulk-assign / import column mapping, and by LeadBulkAssignmentService::eligibleAssignees for equal distribute.
  • Website webhook auto-assign uses eligibleLeadAssignees ∩ receive_website_leads when the endpoint flag assign_to_website_recipients is enabled. Meta Lead Ads does not use this pool.
  • Import equal distribute (assignment_mode=equal): requires leads.assign and that the importer is manager_id on ≥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 (when assigned_to is set) and LeadService::update / assign copy the assignee’s rate to leads.commission_rate on assign/reassign and clear it on unassign. Snapshot is reporting-only (export, list, detail) — no payout engine. Bulk assign and import equal distribute use assign() so snapshots apply there too.
  • Inactivity digests: Workspace settings (group leads): leads.inactivity_working_days (integer, default 3; 0 disables), leads.inactivity_active_only (boolean, default true), leads.inactivity_skip_pending_follow_up (boolean, default true), leads.inactivity_manager_email (boolean, default true), leads.inactivity_assignee_notify (boolean, default true), leads.inactivity_reminder_working_days (integer, default 7; 0 = first threshold crossing only, 1 = daily while idle, otherwise one digest per recipient per N working days). Scheduled command leads:notify-inactive runs 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 meaningful lead_activities since 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 via NotificationIdempotency (period dedupe per recipient). Pest LeadInactiveNotificationTest.
  • Convert opportunity gates: leads.convert_require_opportunity (boolean, default false) and leads.convert_min_opportunity_amount (numeric string, default 0) under group leads. Resolved via TenantSettingService::leadsConvertRequireOpportunity() / leadsConvertMinOpportunityAmount(). Enforced in LeadService::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 in routes/channels.php. Auth (LeadBoardChannel::join): same tenant and leads.view — same shape as LiveChatInboxChannel.
  • Broadcast events (App\Events\Lead\*, all ShouldBroadcastNow, kept separate from the existing domain events of the same base name):
    • LeadCreatedBroadcast → broadcastAs('LeadCreated')
    • LeadUpdatedBroadcast → broadcastAs('LeadUpdated')
    • LeadStageChangedBroadcast → broadcastAs('LeadStageChanged') (includes previous_stage_id)
    • LeadAssignedBroadcast → broadcastAs('LeadAssigned') (includes assigned_to)
    • LeadDeletedBroadcast → broadcastAs('LeadDeleted')
  • Payload shape (LeadBoardBroadcastPayload, shared across all five events): action, lead_id, uuid, actor_id, stage_id, plus previous_stage_id (stage-changed only) / assigned_to (assigned only).
  • Dispatch: LeadEventSubscriber fires the matching broadcast from handleLeadCreated / handleLeadUpdated / handleLeadStageChanged / handleLeadAssigned / handleLeadDeleted, wrapped in a safeBroadcast() try/catch (mirrors LiveChatConversationService::safeBroadcast) so a Reverb outage never breaks the write path — only report()s the exception.
  • Frontend: src/hooks/use-leads-board-realtime.ts joins the channel via the shared window.Echo instance (never connects/disconnects Echo itself) while leads-page.tsx has viewMode === 'board'. Listens for .LeadCreated / .LeadUpdated / .LeadStageChanged / .LeadAssigned / .LeadDeleted and debounces (~200ms) an invalidate of QUERY_KEYS.leadBoard + QUERY_KEYS.leadStats so 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 + CatalogSeeder companion).
  • No Reverb broadcaster is spun up in CI; Pest verifies dispatch via Illuminate\Support\Facades\Event::fake([...Broadcast::class]) + Event::assertDispatched(...), the same approach LiveChatModuleTest uses (Broadcast::fake() is not available on this Laravel version's Broadcast facade). Production readiness: Leads real-time board sync 1.8.0.

Permissions ​

config/tenant-permissions.php:

leads.view | create | update | delete | assign | export | import | convert

Routes use module:leads then can:leads.* / policies.

Import architecture ​

  • Reusable package: Maatwebsite Laravel Excel for CSV/XLSX read + templates
  • Entity-agnostic app/Import framework; Lead is the first handler (future modules add their own handlers)
  • LeadImportMapper casts 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_imports holds 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.

MethodPathPermission
GET/lead-stagesview
GET/leadsview
GET/leads/statsview
GET/leads/boardview
GET/leads/exportexport
GET/leads/import/templateimport
GET/POST/leads/importsimport
GET/PUT/leads/imports/{import}import
PUT/leads/imports/{import}/optionsimport
POST/leads/imports/{import}/previewimport
POST/leads/imports/{import}/runimport
GET/leads/imports/{import}/fileimport
GET/leads/imports/{import}/failed-recordsimport
GET/leads/imports/{import}/error-reportimport
POST/leadscreate
GET/leads/{lead}view
PUT/leads/{lead}update
DELETE/leads/{lead}delete
POST/leads/{lead}/assignassign
POST/leads/{lead}/convertconvert
POST/leads/{lead}/stageupdate
POST/leads/{lead}/notesupdate
POST/leads/{lead}/follow-upsupdate
PUT/leads/{lead}/follow-ups/{followUp}update
POST/leads/{lead}/follow-ups/{followUp}/completeupdate
GET/leads/{lead}/timelineview
GET/leads/{lead}/assignment-historyview

Auth login/me include modules: string[] for SPA gating.

Frontend ​

PiecePath
Pagesrc/pages/leads/leads-page.tsx (board default + table; wires list sheet)
List sheetlead-record-sheet.tsx + shared EntityRecordSheet — New / n / row peek; dedicated routes remain for deep links
Formlead-form.tsx + lead-form-page.tsx (/leads/new, /leads/:id/edit; embed mode for sheet)
Detaillead-view-page.tsx (/leads/:id) — stacked cards; board DnD auto-saves stage on the list page
Import wizardlead-import-dialog.tsx (5-step)
Import historylead-import-history-dialog.tsx
Shared boardsrc/components/crm/kanban-board.tsx (per-column vertical scroll + contained horizontal scroll; titles stay fixed; touch pan on column lists)
Mentions UIsrc/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 registrysrc/notifications/modules/crm.ts (lead.mentioned, lead.duplicate_detected, lead.inactive, lead.inactive_escalation)
Realtime board hooksrc/hooks/use-leads-board-realtime.ts — joins tenant.{id}.leads.board, debounce-invalidates board + stats
ServiceleadService in src/api/services.ts
Navpermission: leads.view, module: 'leads'
Catalog1.8.0 (real-time board sync; 1.7.0 convert require opportunity + min amount settings)

Tests ​

bash
# 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:leads

Logging ​

  • Spatie LogsActivity on Lead (log name leads)
  • Domain lead_activities timeline
  • lead_assignment_histories for assignee changes
  • users.lead_commission_rate, leads.commission_rate — snapshot on assign via LeadService::assign
  • PlatformAuditService via LeadEventSubscriber (+ 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.

Official documentation for the EloSync SaaS Platform.