Lead routing
Assign owners to new contacts and deals with round robin or quota-weighted routing rules.
Lead routing sets owner_id on a contact or deal by picking a user from a rule's pool. Rules are stored as Odden\Sales\Models\LeadRoutingRule records and applied by Odden\Sales\Actions\RouteLeadAction. Routing never runs automatically: call the action where new leads arrive, such as after a form submission or in a created model observer.
Rules
| Attribute | Type | Notes |
|---|---|---|
name |
string | Used in the activity note. |
strategy |
LeadRoutingStrategy |
Defaults to round_robin. |
criteria |
array, nullable | Conditions the record must match. Empty means "match everything". |
assigned_user_ids |
array of user IDs | The pool. Rules with an empty pool are skipped. |
last_assigned_index |
int | Round-robin position. Defaults to 0. |
is_active |
bool | Defaults to true. |
sort_order |
int | Rules are tried in ascending order. |
Odden\Sales\Enums\LeadRoutingStrategy has RoundRobin (round_robin), QuotaWeighted (quota_weighted), and Territory (territory), each with a label().
Routing a record
public function execute(Contact|Deal $target): ?arrayuse Odden\Core\Enums\LeadStatus;
use Odden\Sales\Actions\RouteLeadAction;
use Odden\Sales\Enums\LeadRoutingStrategy;
use Odden\Sales\Models\LeadRoutingRule;
LeadRoutingRule::create([
'name' => 'Inbound round robin',
'strategy' => LeadRoutingStrategy::RoundRobin,
'criteria' => ['lead_status' => LeadStatus::New->value],
'assigned_user_ids' => [$alice->id, $bob->id],
'sort_order' => 10,
]);
$result = app(RouteLeadAction::class)->execute($contact);
if ($result !== null) {
$result['assigned_user_id']; // The new owner
$result['rule']; // The LeadRoutingRule that matched
}The action goes through active rules in sort_order. The first rule whose criteria match and whose pool is not empty picks a user. The action then:
- updates the record's
owner_id(with model events), and - logs a note activity on the record titled
Lead Routed to {user name}.
It returns null, and changes nothing, if no rule matches. It assigns an owner even if the record already has one.
Criteria
Criteria are an array of key/value pairs that must all match. The supported keys depend on the record type, and unknown keys are ignored.
| Record | Key | Matches when |
|---|---|---|
| Contact | lead_status |
The contact's lead_status value equals the expected string, for example 'new'. |
| Contact | timezone |
The contact's timezone equals the expected value. |
| Contact | city |
The city custom property of the contact's first company equals the expected value. |
| Deal | pipeline_id |
The deal's pipeline_id equals the expected ID. |
| Deal | min_amount |
The deal's amount is at least the expected value. |
A key that doesn't apply to the record type (for example min_amount on a contact) is ignored, so a rule meant for deals can also match contacts. Use different rules, ordered with sort_order, if you route both.
LeadRoutingRule::create([
'name' => 'Enterprise deals',
'strategy' => LeadRoutingStrategy::Territory,
'criteria' => ['pipeline_id' => $pipeline->id, 'min_amount' => 50000],
'assigned_user_ids' => [$alice->id],
]);
app(RouteLeadAction::class)->execute($deal);Strategies
Round robin
RoundRobin and Territory both pick users in rotation. Each assignment advances last_assigned_index by one (wrapping at the end of the pool) and assigns the user at the new index. Because the index starts at 0, the first lead of a new rule goes to the second user in the pool, then the first, and so on. Territory has no extra behavior; the "territory" is whatever you put in criteria.
Quota-weighted
QuotaWeighted assigns the lead to the pool member who is furthest behind on quota. For each user it looks up their most recent SalesQuota whose period includes today, then picks:
- the lowest
attainment_percent, - on a tie, the largest
gap_to_target, - on a further tie, the earliest position in the pool.
Users with no current quota count as 0% attainment with no gap, so they are favored over users who have made progress. The chosen user's position is stored in last_assigned_index.