›
byrcsc/laravel-customer-health · 1.x
Health score declarations combine weighted signals into explainable values, states, and history.
Use a health score when customer state should be calculated from several observable signals. Each computation stores the inputs and contributions that produced its value.
A score extends HealthScore and declares signals plus state thresholds:
namespace App\CustomerHealth;
use ByRcsc\LaravelCustomerHealth\Scoring\HealthScore;
use ByRcsc\LaravelCustomerHealth\Scoring\Signals\FeatureActivity;
use ByRcsc\LaravelCustomerHealth\Scoring\Signals\FeatureAdopted;
use ByRcsc\LaravelCustomerHealth\Scoring\Signals\RecentActivity;
final class CustomerHealthScore extends HealthScore
{
public static string $name = 'customer_health';
public function signals(): array
{
return [
new RecentActivity(days: 30, weight: 2),
new FeatureAdopted(feature: 'workflows', weight: 1),
new FeatureActivity(feature: 'reports', days: 30, weight: 1),
];
}
public function states(): array
{
return [
'at_risk' => 0,
'needs_attention' => 50,
'healthy' => 75,
];
}
}The default name is the snake-cased class basename. Names must be non-empty and unique among registered scores.
Every signal returns an integer from 0 through 100 and a finite positive weight. The package normalizes weights before multiplying each raw value.
Weights of 2, 1, 1 therefore become 0.5, 0.25, 0.25. Scaling every weight
by the same factor does not change the result.
The final sum is rounded to an integer. A signal value outside 0 through 100
throws InvalidSignalValueException and stores no score.
| Signal | Result |
|---|---|
RecentActivity($days, $weight) | 100 when any event falls inside the inclusive UTC window |
FeatureAdopted($feature, $weight) | 100 when the feature has a permanent milestone |
FeatureActivity($feature, $days, $weight) | 100 when the feature has activity inside the window |
DistinctActors($days, $weight) | 100 when any identified actor has activity inside the window |
OnboardingProgress($checklist, $weight) | The checklist percentage from 0 through 100 |
The built-in activity signals are binary. Write a custom signal when a count, ratio, contract value, or another application rule should produce intermediate values.
Implement Signal:
namespace App\CustomerHealth\Signals;
use ByRcsc\LaravelCustomerHealth\Contracts\Trackable;
use ByRcsc\LaravelCustomerHealth\Scoring\Signal;
final readonly class SeatUtilization implements Signal
{
public function __construct(private float $signalWeight) {}
public function evaluate(Trackable $subject): int
{
$used = $subject->users()->count();
$available = max(1, $subject->seat_limit);
return min(100, (int) round(($used / $available) * 100));
}
public function weight(): float
{
return $this->signalWeight;
}
}Implement WindowedSignal as well when the signal reads a declared day window.
Its windowDays() method lets the recompute command warn when raw-event
retention is shorter than a scoring window.
State thresholds are inclusive lower bounds. The package sorts thresholds by value, then selects the highest threshold less than or equal to the score.
Every score must declare one state starting at 0. State names must be
non-empty, thresholds must be unique integers from 0 through 100, and the
states array cannot be empty.
$result = CustomerHealth::compute($team, 'customer_health');
$result->score;
$result->value;
$result->state;
$result->breakdown;
$result->computedAt;
$latest = CustomerHealth::score($team, 'customer_health');
$history = CustomerHealth::scoreHistory($team, 'customer_health');Omit the score argument to use the first registered score. score() returns
null before the first computation. scoreHistory() returns stored results
in ascending computation order.
Each computation dispatches HealthScoreComputed after its transaction
commits. HealthStateChanged fires when the previous and current states
differ, including the first computation where $from is null.