A form submission sitting in an inbox for six hours before a sales rep sees it is a genuinely common way for a warm lead to go cold, and it's also one of the more mechanical, rule-based problems in a sales process — which makes it a good candidate for automation. Here's the actual build, step by step, including the routing rule that quietly sent every single lead to the same rep for four days before anyone noticed.
The pieces of the pipeline
Four steps, each doing one job: capture the form submission, enrich it with a bit of context the form itself doesn't collect, score it against a simple rubric, and assign it to the right rep's queue in the CRM. Keeping these as separate, independently testable steps — rather than one large function trying to do all four — made the eventual bug much faster to isolate than a single monolithic handler would have been.
Step 1: capturing the submission
The form posts to a webhook endpoint the moment someone submits it, rather than relying on a batch export or a polling job — the whole point of automating this is speed, and polling every fifteen minutes defeats that purpose for a fast-moving lead.
Route::post('/webhooks/lead-form', [LeadWebhookController::class, 'handle']);
class LeadWebhookController extends Controller
{
public function handle(Request $request)
{
$validated = $request->validate([
'email' => 'required|email',
'company' => 'required|string',
'company_size' => 'nullable|string',
'message' => 'nullable|string',
]);
ProcessNewLead::dispatch($validated);
return response()->json(['status' => 'received'], 202);
}
}
Returning a fast 202 and dispatching the real work to a queued job matters here — the form-submission page shouldn't hang waiting for enrichment, scoring, and a CRM API call to all finish synchronously before the visitor sees a confirmation message.
Step 2: enrichment — filling in what the form didn't ask
The form only collects email, company name, and an optional message. A short enrichment step looks up the company's approximate size and industry from the email domain, using a lightweight lookup rather than a full paid enrichment API for this first version:
class ProcessNewLead implements ShouldQueue
{
public function handle(EnrichmentService $enrichment, ScoringService $scoring, RoutingService $routing): void
{
$domain = Str::after($this->lead['email'], '@');
$enriched = $enrichment->lookupDomain($domain);
$lead = array_merge($this->lead, [
'estimated_company_size' => $enriched['size'] ?? null,
'industry' => $enriched['industry'] ?? null,
'is_free_email_provider' => $enrichment->isFreeProvider($domain),
]);
$score = $scoring->score($lead);
$routing->assign($lead, $score);
}
}
The is_free_email_provider flag turned out to matter more than expected — a lead submitting from a personal Gmail address behaves very differently, on average, from one submitting from a company domain, and folding that signal into scoring rather than treating every submission identically improved routing quality noticeably.
Step 3: a simple, transparent scoring rubric
We deliberately kept scoring rule-based rather than reaching for a model to predict lead quality — with a few hundred leads a month, there wasn't enough data to train anything meaningful, and a transparent rubric a sales manager can read and adjust in five minutes beats an opaque score nobody can explain when a rep asks why a lead was ranked the way it was.
class ScoringService
{
public function score(array $lead): int
{
$score = 0;
$score += match (true) {
($lead['estimated_company_size'] ?? 0) >= 200 => 30,
($lead['estimated_company_size'] ?? 0) >= 50 => 20,
($lead['estimated_company_size'] ?? 0) >= 10 => 10,
default => 0,
};
$score += $lead['is_free_email_provider'] ? -15 : 10;
$score += ! empty($lead['message']) ? 10 : 0;
return max(0, min(100, $score));
}
}
Simple, readable, and — critically — adjustable by someone who isn't a developer, just by changing numbers in a config file rather than touching code, once we moved the thresholds out into a settings table a few weeks in.
Step 4: routing — and the bug that assigned everything to one rep
The routing logic assigned leads round-robin among available reps, filtered by territory when a company's inferred region was known. The first version had a subtle bug in the round-robin counter:
// The bug: counter was scoped per-request, not persisted, so it
// reset to 0 on every single invocation of the queued job
class RoutingService
{
private int $counter = 0;
public function assign(array $lead, int $score): void
{
$reps = $this->availableReps($lead);
$rep = $reps[$this->counter % count($reps)];
$this->counter++;
$this->assignToRep($lead, $rep);
}
}
Because RoutingService was resolved fresh out of the container on every queued job invocation, $counter reset to zero every single time, which meant $this->counter % count($reps) evaluated to the same index — the first rep in the array — on every single lead, every single time. For four days, every new lead in the system was assigned to the same rep, while the rest of the team's queues sat empty. Nobody caught it immediately because the automation was technically working: leads were being captured, scored, and assigned without errors. The dashboard just didn't have an obvious "leads per rep" view yet, so the imbalance wasn't visible until someone happened to notice one rep's queue was unusually full.
// The fix: persist the counter outside the request lifecycle
class RoutingService
{
public function assign(array $lead, int $score): void
{
$reps = $this->availableReps($lead);
$index = Cache::increment('lead_routing_counter') % count($reps);
$rep = $reps[$index];
$this->assignToRep($lead, $rep);
}
}
Moving the counter into a shared cache store, incremented atomically, fixed the immediate bug. It also prompted a broader habit: any piece of state a workflow depends on for correctness needs to live somewhere that survives across invocations, and it's worth explicitly asking "where does this variable actually live between runs" for every piece of state in an automation, not just assuming a class property will behave the way it would in a long-running process.
What we added after the bug: a sanity check, not just a fix
Fixing the counter fixed the specific bug. It didn't prevent the next possible version of the same category of failure — a routing rule that's technically running without errors but producing a badly skewed distribution for some other reason. A lightweight daily check now compares the count of leads assigned per rep against an expected rough distribution and flags a Slack alert if any one rep received more than double the average over the trailing week:
class CheckLeadDistribution
{
public function handle(): void
{
$counts = Lead::where('assigned_at', '>=', now()->subWeek())
->groupBy('assigned_rep_id')
->selectRaw('assigned_rep_id, count(*) as total')
->pluck('total', 'assigned_rep_id');
$average = $counts->avg();
foreach ($counts as $repId => $total) {
if ($total > $average * 2) {
Notification::route('slack', config('services.slack.ops_channel'))
->notify(new UnbalancedLeadRoutingAlert($repId, $total, $average));
}
}
}
}
This is the same instinct behind the debugging framework in why your automation keeps silently failing — a workflow that runs without throwing an error isn't the same thing as a workflow producing the correct result, and the only way to catch the second category is a check that specifically looks for it, rather than relying on error logs that a silently-wrong workflow will never populate.
What we'd build differently starting over
- Add the distribution check from day one, not after a four-day bug. It's cheap to build and would have caught the round-robin bug on day one instead of day four.
- Keep scoring rule-based until there's enough volume to justify anything more complex. A rubric a manager can read and adjust beats an opaque score, especially early on when getting the rules right matters more than optimizing them.
- Treat any counter, cursor, or piece of running state as suspect until you've explicitly confirmed where it lives and whether it survives between invocations — this is the single most common source of silent automation bugs we've hit across several projects, not just this one.
If you're building something with a similar shape but a different trigger — a Slack message instead of a form, say — the same enrichment-then-route pattern applies, and building a Slack bot that actually gets used covers the trigger side of that variation in more depth.
Handling the edge case of "no reps available"
The round-robin fix solved the distribution problem, but it exposed a second gap during testing: what happens when availableReps() returns an empty array, because every rep on a given territory is marked out of office at the same time? The original code would have thrown a division-by-zero-adjacent error on the modulo operation against a zero-length array, which — worse than a visible crash — was wrapped in a queued job's try/catch that logged the exception and moved on, meaning the lead would have simply never been assigned to anyone, with no alert and no retry.
public function assign(array $lead, int $score): void
{
$reps = $this->availableReps($lead);
if (empty($reps)) {
// Fall back to a default catch-all queue rather than
// silently failing to assign the lead to anyone
$this->assignToFallbackQueue($lead);
Notification::route('slack', config('services.slack.ops_channel'))
->notify(new NoRepsAvailableAlert($lead));
return;
}
$index = Cache::increment('lead_routing_counter') % count($reps);
$this->assignToRep($lead, $reps[$index]);
}
The general lesson here generalizes past this one workflow: any automation that selects an item from a dynamically-sized list — reps, channels, categories — needs an explicit answer for what happens when that list is empty, and "the code will just throw an exception that gets logged and ignored" is not an acceptable answer for anything customer-facing, even if it technically satisfies "the job didn't crash the queue worker."
Testing this without waiting for real leads
Because the routing bug only became visible after real leads accumulated over several days, we added a seed script that simulates a burst of leads against the pipeline in a test environment, specifically to catch distribution issues before shipping any future change to the routing logic:
php artisan tinker --execute="
collect(range(1, 50))->each(fn (\$i) => ProcessNewLead::dispatchSync([
'email' => \"test{\$i}@example.com\",
'company' => \"Test Co {\$i}\",
]));
\$distribution = App\Models\Lead::groupBy('assigned_rep_id')
->selectRaw('assigned_rep_id, count(*) as total')
->pluck('total', 'assigned_rep_id');
dump(\$distribution);
"
Running 50 simulated leads through the pipeline and eyeballing the resulting distribution takes under a minute and would have caught the original bug immediately — every one of the 50 landing on the same rep is an obvious, visible signal in a way that four days of real, gradually accumulating leads was not.
No comments yet.
Be the first visitor to add a thoughtful comment on this article.