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
Subscriberapp/Listeners/LeadEventSubscriber.php (audit + notifications)
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, 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”.
  • 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, status closed, activity type converted. 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 and the lead has a non-empty company string, creates or reuses a Company by case-insensitive name (conversion_meta.company_id, Contact company_id) 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. Stub converts (no contact_id) can be completed by calling convert again after Contacts is installed (company/opportunity already in conversion_meta are reused and linked; linking an existing opportunity requires opportunities.update). 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.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 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 alerts: Workspace setting leads.inactivity_working_days (integer, default 3; 0 disables). 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. 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 via NotificationIdempotency (daily dedupe per lead/recipient).

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)
ServiceleadService in src/api/services.ts
Navpermission: leads.view, module: 'leads'
Catalog1.4.0 (hybrid list sheets)

Tests

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