Lead Scoring System Design for a CRM in Laravel

Lead scoring system design for a CRM: fit and engagement signals, database-driven rules, event-driven scoring and score decay, with Laravel 11 code examples.

Lead scoring system design is about turning scattered lead activity into one number a sales team actually trusts. I have built lead management into several CRM platforms, and the lesson is always the same: sales teams do not want more data, they want to know who to call first.

In this article I walk through the architecture I use: separating fit and engagement signals, storing rules in the database so managers can tune them, scoring through events and queued listeners, decaying old activity, and keeping an audit trail so every score can be explained. The code is Laravel 11 and MySQL, but the design works in any stack.

Prerequisites

  • A Laravel 11 application on PHP 8.3 with a leads table
  • MySQL 8 or PostgreSQL
  • A queue worker running (database or Redis driver)
  • Agreement with sales on what a qualified lead looks like, this matters more than the code

Two kinds of lead scoring signals: fit and engagement

Fit signals describe who the lead is: industry, company size, job title, country, budget. Engagement signals describe what they did: opened an email, visited the pricing page, booked a demo, replied to a sequence.

Score them separately, then combine. A perfect-fit lead with no engagement needs nurturing, not a call. A highly engaged student who will never buy should not jump to the top of the list. Keeping two numbers lets you build a simple grid: high fit and high engagement go to sales today, high fit and low engagement go to marketing, low fit gets deprioritised.

Lead scoring database design: rules in tables, not in code

Hard-coded points become a deployment every time sales changes its mind, which is often. Store rules in a table so managers can tune them from an admin screen. Every scoring event is also stored as a row, which gives you history, decay and an explanation for every score.

Schema::create('scoring_rules', function (Blueprint $t) {
    $t->id();
    $t->string('type');                 // 'fit' or 'engagement'
    $t->string('event')->nullable();    // e.g. 'demo_booked', 'pricing_viewed'
    $t->string('field')->nullable();    // for fit rules, e.g. 'company_size'
    $t->string('operator')->nullable(); // '=', '>=', 'in'
    $t->json('value')->nullable();
    $t->integer('points');              // can be negative
    $t->unsignedInteger('max_per_lead')->nullable();
    $t->boolean('active')->default(true);
    $t->timestamps();
});

Schema::create('lead_score_events', function (Blueprint $t) {
    $t->id();
    $t->foreignId('lead_id')->constrained()->cascadeOnDelete();
    $t->foreignId('scoring_rule_id')->constrained();
    $t->integer('points');
    $t->timestamp('occurred_at');
    $t->index(['lead_id', 'occurred_at']);
});

Schema::table('leads', function (Blueprint $t) {
    $t->integer('fit_score')->default(0);
    $t->integer('engagement_score')->default(0);
    $t->string('grade', 2)->nullable(); // A1, B2 ...
    $t->timestamp('scored_at')->nullable();
});

Step 1: Record activity as domain events

Anywhere a lead does something meaningful, dispatch one event. The tracking code does not know anything about scoring, which keeps it clean and lets you add new rules without touching it.

// app/Events/LeadActivityRecorded.php
class LeadActivityRecorded
{
    use Dispatchable;

    public function __construct(
        public Lead $lead,
        public string $event,
        public ?\DateTimeInterface $at = null,
    ) {}
}

// e.g. in a webhook from your email provider
LeadActivityRecorded::dispatch($lead, 'email_clicked');

// or when a demo is booked
LeadActivityRecorded::dispatch($lead, 'demo_booked');

Step 2: Event-driven lead scoring with a queued listener

The listener looks up the active rule, respects the per-lead cap, writes a score event and recalculates. It is queued so tracking pixels and webhooks respond instantly. Laravel 11 discovers listeners automatically from the type-hinted handle method.

// app/Listeners/ScoreLeadActivity.php
class ScoreLeadActivity implements ShouldQueue
{
    public function handle(LeadActivityRecorded $e): void
    {
        $rule = ScoringRule::where('active', true)
            ->where('type', 'engagement')
            ->where('event', $e->event)
            ->first();

        if (! $rule) {
            return;
        }

        if ($rule->max_per_lead !== null) {
            $count = LeadScoreEvent::where('lead_id', $e->lead->id)
                ->where('scoring_rule_id', $rule->id)
                ->count();

            if ($count >= $rule->max_per_lead) {
                return;
            }
        }

        LeadScoreEvent::create([
            'lead_id'         => $e->lead->id,
            'scoring_rule_id' => $rule->id,
            'points'          => $rule->points,
            'occurred_at'     => $e->at ?? now(),
        ]);

        app(LeadScorer::class)->recalculate($e->lead);
    }
}

Step 3: Calculate fit score from lead attributes

Fit rules compare a lead field with a value. They are recalculated whenever the lead is saved, because fit changes when data is enriched or a form is updated.

class LeadScorer
{
    public function fit(Lead $lead): int
    {
        return ScoringRule::where('active', true)
            ->where('type', 'fit')
            ->get()
            ->filter(fn (ScoringRule $r) => $this->matches($lead->{$r->field}, $r->operator, $r->value))
            ->sum('points');
    }

    private function matches(mixed $actual, string $op, mixed $expected): bool
    {
        return match ($op) {
            '='     => $actual == $expected,
            '>='    => is_numeric($actual) && $actual >= $expected,
            '<='    => is_numeric($actual) && $actual <= $expected,
            'in'    => in_array($actual, (array) $expected, true),
            default => false,
        };
    }
}

Step 4: Lead score decay over time

A demo request from last week means much more than a newsletter click from last year. Without decay, old leads accumulate points forever and crowd the top of the list. I use a half-life: an engagement event loses half its value every 30 days. Because every event is stored with its date, the engagement score can be recomputed at any time.

public function engagement(Lead $lead, int $halfLifeDays = 30): int
{
    $score = LeadScoreEvent::where('lead_id', $lead->id)
        ->where('occurred_at', '>=', now()->subDays($halfLifeDays * 6))
        ->get(['points', 'occurred_at'])
        ->sum(function ($e) use ($halfLifeDays) {
            $age = $e->occurred_at->diffInDays(now());
            return $e->points * (0.5 ** ($age / $halfLifeDays));
        });

    return (int) round($score);
}

public function recalculate(Lead $lead): void
{
    $fit = $this->fit($lead);
    $engagement = $this->engagement($lead);

    $lead->forceFill([
        'fit_score'        => $fit,
        'engagement_score' => $engagement,
        'grade'            => $this->grade($fit, $engagement),
        'scored_at'        => now(),
    ])->saveQuietly();
}

private function grade(int $fit, int $engagement): string
{
    $letter = $fit >= 60 ? 'A' : ($fit >= 30 ? 'B' : 'C');
    $number = $engagement >= 50 ? '1' : ($engagement >= 20 ? '2' : '3');
    return $letter . $number;
}

Step 5: Recalculate nightly with the scheduler

Decay only changes with time, so a nightly job refreshes leads that had activity in the last few months. Chunking keeps memory flat on large tables.

// routes/console.php
Schedule::call(function (LeadScorer $scorer) {
    Lead::whereHas('scoreEvents', fn ($q) => $q->where('occurred_at', '>=', now()->subDays(180)))
        ->chunkById(500, fn ($leads) => $leads->each(fn ($lead) => $scorer->recalculate($lead)));
})->dailyAt('02:00')->name('lead-score-decay')->withoutOverlapping();

Explainable scores: show sales why

The fastest way to lose sales trust is a number nobody can explain. On the lead page I show the last ten score events with their rule name and points, plus the fit rules that matched. When a rep asks why a lead is A1, the answer is on screen: booked a demo yesterday, visited pricing three times, company size over 200.

Common lead scoring mistakes and fixes

  • Scores only ever go up: add decay and negative rules such as unsubscribed or bounced email
  • One noisy event dominates: cap repeating events with max_per_lead
  • Bots and internal staff inflate engagement: exclude your own domain and known crawler user agents before dispatching events
  • Rules changed but scores did not: provide a recalculate-all action after rule edits, run it as a queued batch
  • Thresholds picked by guesswork: compare scores against closed-won deals every quarter and adjust

Best practices for CRM lead scoring

  • Start with five to ten rules that sales agree on, not fifty
  • Keep fit and engagement as separate scores and combine them only in the grade
  • Store every scoring event for history, decay and auditing
  • Make scoring asynchronous so tracking never slows down user-facing requests
  • Let managers edit rules in the admin, but log who changed what
  • Review the model against real conversions on a regular schedule

Conclusion

Good lead scoring system design is less about clever maths and more about trust. Separate who the lead is from what they did, keep rules in the database, score through events, let old activity decay, and show the reasons behind every number. When I delivered this structure to sales teams, the question changed from "is this score right?" to "who is next on the list?", which is exactly the point.

Frequently asked questions

What is a good lead scoring model for a CRM?

A model that separates fit (who the lead is) from engagement (what they did) and combines them into a grade. It should be simple enough that sales can understand and trust each score.

How do you implement lead score decay?

Store every scoring event with a timestamp and reduce its value with age, for example halving it every 30 days. Recalculate engagement on a schedule so scores fall when activity stops.

Should lead scoring rules be hard-coded?

No. Store them in a database table so managers can adjust points and thresholds without a deployment. Log rule changes so you can explain shifts in scores.

What are negative lead scoring signals?

Actions that make a lead less likely to buy, such as unsubscribing, bounced emails, competitor domains or visiting the careers page. Give them negative points.

Can lead scoring use machine learning instead of rules?

Yes, once you have enough historical conversions to train on. Most CRMs should start with transparent rules and add a predictive score later alongside them.