PHPackages                             aftandilmmd/laravel-model-scores - PHPackages - PHPackages  [Skip to content](#main-content)[PHPackages](/)[Directory](/)[Categories](/categories)[Trending](/trending)[Leaderboard](/leaderboard)[Changelog](/changelog)[Analyze](/analyze)[Collections](/collections)[Log in](/login)[Sign up](/register)

1. [Directory](/)
2. /
3. [Utility &amp; Helpers](/categories/utility)
4. /
5. aftandilmmd/laravel-model-scores

ActiveLibrary[Utility &amp; Helpers](/categories/utility)

aftandilmmd/laravel-model-scores
================================

Flexible quality scoring system for Laravel models

v1.1.1(2mo ago)50MITPHPPHP ^8.2

Since Feb 15Pushed 2mo agoCompare

[ Source](https://github.com/aftandilmmd/laravel-model-scores)[ Packagist](https://packagist.org/packages/aftandilmmd/laravel-model-scores)[ RSS](/packages/aftandilmmd-laravel-model-scores/feed)WikiDiscussions main Synced 1mo ago

READMEChangelog (2)Dependencies (2)Versions (4)Used By (0)

Laravel Model Scores
====================

[](#laravel-model-scores)

[![Business Score](screenshots/business-score.png)](screenshots/business-score.png)

A flexible scoring system for Laravel models. Add points, penalties, and badges to any Eloquent model — no setup required. When you're ready, go deeper with calculators, task groups, decay, event sourcing, and more.

**[Turkish (TR)](README.tr.md)** | **[Azerbaijani (AZ)](README.az.md)**

Why This Package?
-----------------

[](#why-this-package)

Most Laravel applications eventually need to score, rank, or rate something — a vendor's reliability, a user's profile completeness, a listing's quality. You start with a few `if` statements, then add weights, then need history tracking, then someone asks for badges. Before long, scoring logic is scattered across your codebase with no audit trail and no consistency.

Laravel Model Scores gives you a structured way to handle this. Define each scoring criterion as an isolated calculator class, group them logically, assign weights, and let the package handle the rest — badge transitions, event sourcing, score decay, and bulk recalculation.

**Common use cases:**

- **Marketplace quality scores** — Score vendors on profile completeness, response rates, reviews, and fulfillment metrics. Think Airbnb Superhost or Etsy Star Seller.
- **Profile completion** — Drive users to complete their profiles with a checklist and progress bar. Each missing field is a scoring task.
- **Gamification and loyalty tiers** — Award points for engagement, purchases, or content creation. Assign Bronze/Silver/Gold badges automatically based on score ranges.
- **Compliance scoring** — Score organizations on safety audits, regulatory adherence, or process completion. Decay ensures stale compliance degrades over time.
- **Content and listing quality** — Score products or articles on data completeness, image count, and description quality. Use scores for search ranking.

**When to use it:**

- You have multiple independent scoring criteria
- Criteria use different logic (boolean checks, proportional metrics, inverse ratios, tiered thresholds)
- You need an audit trail of score changes
- You want badges or tiers that update automatically
- Some metrics should decay if not refreshed

**When you probably don't need it:**

- A single integer counter is enough (just use a column)
- You only need user-submitted star ratings (use a reviews package)
- Your "score" is one computed value with no history requirement

Requirements
------------

[](#requirements)

- PHP 8.2+
- Laravel 11 or 12

Installation
------------

[](#installation)

```
composer require aftandilmmd/laravel-model-scores
```

Publish the config and migrations:

```
php artisan vendor:publish --tag=model-scores-config
php artisan vendor:publish --tag=model-scores-migrations
php artisan migrate
```

Quick Start
-----------

[](#quick-start)

### 1. Add the Trait

[](#1-add-the-trait)

```
use Aftandilmmd\LaravelModelScores\Traits\HasModelScores;

class Tenant extends Model
{
    use HasModelScores;
}
```

### 2. Add &amp; Remove Points

[](#2-add--remove-points)

```
// Add bonus points (with optional expiry)
$tenant->addScoreBonus(20, 'Welcome bonus', now()->addDays(30));

// Add penalty
$tenant->addScorePenalty(10, 'Late payment');

// Read the total score
$total = ModelScore::getTotalScore($tenant);
```

### 3. Get Current Badge

[](#3-get-current-badge)

```
$badge = $tenant->scoreBadge();
// $badge->name, $badge->color, $badge->icon
```

### 4. View Score History

[](#4-view-score-history)

```
$history = $tenant->scoreHistory(days: 30);
// [{ date, total, previous_total, change }, ...]
```

That's the basics — bonus/penalty, badges, history. No calculators or tasks needed.

### Using the Facade

[](#using-the-facade)

The `ModelScore` facade gives you the same capabilities plus bulk operations:

```
use Aftandilmmd\LaravelModelScores\Facades\ModelScore;

// Add adjustment via facade
ModelScore::addAdjustment($tenant, 20, 'bonus', 'Welcome bonus', now()->addDays(30));

// Revoke an adjustment
ModelScore::revokeAdjustment($adjustment);

// Query adjustments
$active = ModelScore::getActiveAdjustments($tenant);
$total = ModelScore::getAdjustmentsTotal($tenant);

// Get all available badges
$badges = ModelScore::getAvailableBadges();
```

### Caching Score on Model (Optional)

[](#caching-score-on-model-optional)

By default, the total score is computed from the database on each read. For faster access, you can cache it on your model's table:

1. Add a column to your migration:

```
$table->unsignedInteger('model_score')->default(0);
```

2. Set it in config:

```
// config/model-scores.php
'score_column' => 'model_score',
```

Now `$tenant->model_score` is always available and updated automatically.

---

Advanced Usage
==============

[](#advanced-usage)

Everything below is optional — use what you need.

Calculator System
-----------------

[](#calculator-system)

Instead of manual bonuses/penalties, define **scoring tasks** with calculators that automatically evaluate your models.

### Create a Calculator

[](#create-a-calculator)

Each scoring criterion gets its own calculator class:

```
use Aftandilmmd\LaravelModelScores\Calculators\BaseCalculator;
use Illuminate\Database\Eloquent\Model;

class ProfilePhotoCalculator extends BaseCalculator
{
    public function calculate(Model $scoreable, int $maxPoints, array $taskMetadata = []): array
    {
        return $this->binaryScore(
            ! empty($scoreable->profile_photo),
            $maxPoints
        );
    }
}
```

### Register Tasks

[](#register-tasks)

Define what's being scored via seeder or migration:

```
use Aftandilmmd\LaravelModelScores\Models\ModelScoreTask;

ModelScoreTask::create([
    'key' => 'profile_photo',
    'name' => 'Upload Profile Photo',
    'calculator' => ProfilePhotoCalculator::class,
    'type' => 'static',
    'max_points' => 50,
]);
```

### Calculate Scores

[](#calculate-scores)

```
// Calculate all tasks for a model
$tenant->calculateScore();

// Only static or periodic tasks
$tenant->calculateScore('static');

// Calculate for all models (chunks of 100)
ModelScore::calculateForAll(Tenant::class);

// With a custom query
ModelScore::calculateForAll(Tenant::class, query: Tenant::where('is_active', true));
```

### Query Results

[](#query-results)

```
// Detailed breakdown per task
$breakdown = $tenant->scoreBreakdown();

// Checklist items (for wizard UI)
$checklist = $tenant->scoreChecklist();
```

Calculator Reference
--------------------

[](#calculator-reference)

`BaseCalculator` provides four helper methods for common scoring patterns. Every helper returns the same structure:

```
['score' => int, 'metadata' => array]
```

You can also pass custom `$metadata` to any helper — it will be stored alongside the score for debugging or display purposes.

---

### `binaryScore` — All or Nothing

[](#binaryscore--all-or-nothing)

Awards full points when a condition is met, zero otherwise. Use for yes/no checks like "has a profile photo" or "has verified email".

```
$this->binaryScore(bool $condition, int $maxPoints, array $metadata = []): array
```

ParameterTypeDescription`$condition``bool`The check to evaluate`$maxPoints``int`Points awarded when `true`**Formula:** `$condition ? $maxPoints : 0`

**Example — Host identity verification (Airbnb Superhost):**

```
class HostIdentityVerifiedCalculator extends BaseCalculator
{
    public function calculate(Model $scoreable, int $maxPoints, array $taskMetadata = []): array
    {
        return $this->binaryScore(
            ! empty($scoreable->identity_verified_at),
            $maxPoints,
            ['verified' => ! empty($scoreable->identity_verified_at)]
        );
    }
}
```

ScenariomaxPointsResultVerified50`['score' => 50, 'metadata' => ['verified' => true]]`Not verified50`['score' => 0, 'metadata' => ['verified' => false]]`---

### `proportionalScore` — Linear Ratio

[](#proportionalscore--linear-ratio)

Awards points proportional to a ratio between 0.0 and 1.0. The ratio is clamped — values below 0 become 0, above 1 become 1. Use when "more is better" up to a target.

```
$this->proportionalScore(float $ratio, int $maxPoints, array $metadata = []): array
```

ParameterTypeDescription`$ratio``float`Value between 0.0–1.0 (clamped automatically)`$maxPoints``int`Maximum achievable points**Formula:** `round(clamp($ratio, 0, 1) * $maxPoints)`

**Example — Host response rate (Airbnb requires 90%+ for Superhost):**

```
class ResponseRateCalculator extends BaseCalculator
{
    public function calculate(Model $scoreable, int $maxPoints, array $taskMetadata = []): array
    {
        $total = $scoreable->inquiries()->where('created_at', '>=', now()->subYear())->count();
        $responded = $scoreable->inquiries()->where('created_at', '>=', now()->subYear())
            ->whereNotNull('responded_at')->count();

        $rate = $total > 0 ? $responded / $total : 1.0;

        return $this->proportionalScore($rate, $maxPoints, [
            'total_inquiries' => $total,
            'responded' => $responded,
            'response_rate' => round($rate * 100, 1),
        ]);
    }
}
```

Response RateRatiomaxPointsScore100%1.010010090%0.91009050%0.5100500%0.01000---

### `inverseScore` — Lower is Better

[](#inversescore--lower-is-better)

Awards higher points when a ratio is low. The score decreases linearly from `$maxPoints` (at ratio 0) to 0 (at ratio &gt;= threshold). Use for metrics where less is better, like cancellation rates or complaint ratios.

```
$this->inverseScore(float $ratio, float $threshold, int $maxPoints, array $metadata = []): array
```

ParameterTypeDescription`$ratio``float`The current rate (e.g. 0.15 for 15%)`$threshold``float`The rate at which score becomes 0 (e.g. 0.20 for 20%)`$maxPoints``int`Points awarded when ratio is 0**Formula:** `ratio >= threshold ? 0 : round((1 - ratio / threshold) * maxPoints)`

**Example — Host cancellation rate (Airbnb Superhost requires &lt; 1%):**

```
class CancellationRateCalculator extends BaseCalculator
{
    public function calculate(Model $scoreable, int $maxPoints, array $taskMetadata = []): array
    {
        $total = $scoreable->reservations()->where('check_in', '>=', now()->subYear())->count();
        $cancelled = $scoreable->reservations()->where('check_in', '>=', now()->subYear())
            ->where('cancelled_by', 'host')->count();

        $rate = $total > 0 ? $cancelled / $total : 0;

        return $this->inverseScore($rate, 0.05, $maxPoints, [
            'total_reservations' => $total,
            'cancelled_by_host' => $cancelled,
            'cancellation_rate' => round($rate * 100, 2),
        ]);
    }
}
```

Cancel RateThresholdmaxPointsScore0% (0.00)0.051001001% (0.01)0.05100802.5% (0.025)0.05100504% (0.04)0.05100205%+ (0.05)0.051000---

### `tieredScore` — Threshold Tiers

[](#tieredscore--threshold-tiers)

Awards points based on which tier a value falls into. Each tier maps a minimum value to a ratio (0.0–1.0), and the highest matching tier's ratio is used with `proportionalScore`. Use for step-based scoring like "upload at least 5 photos for 50%".

```
$this->tieredScore(float $value, array $tiers, int $maxPoints, array $metadata = []): array
```

ParameterTypeDescription`$value``float`The measured value (e.g. image count)`$tiers``array``[threshold => ratio]` pairs, e.g. `[0 => 0.0, 5 => 0.5, 10 => 1.0]``$maxPoints``int`Maximum achievable points**Formula:** Find the highest tier where `$value >= threshold`, take its ratio, then `round(ratio * maxPoints)`.

**Example — Listing photos (Airbnb recommends 20+ photos):**

```
class ListingPhotosCalculator extends BaseCalculator
{
    public function calculate(Model $scoreable, int $maxPoints, array $taskMetadata = []): array
    {
        return $this->tieredScore($scoreable->photos()->count(), [
            0  => 0.0,   // No photos
            1  => 0.15,  // At least one — listing is visible
            5  => 0.35,  // Basic coverage of the space
            10 => 0.60,  // Good — each room shown
            15 => 0.80,  // Detailed — amenities and neighborhood
            20 => 1.0,   // Professional-level listing
        ], $maxPoints);
    }
}
```

PhotosMatched TierRatiomaxPointsScore0`0 => 0.0`0.08003`1 => 0.15`0.1580127`5 => 0.35`0.35802812`10 => 0.60`0.60804825`20 => 1.0`1.08080---

### Choosing the Right Calculator Type

[](#choosing-the-right-calculator-type)

TypeBest forExample**Binary**Yes/no conditionsIdentity verified, email confirmed, profile photo uploaded**Proportional**"More is better" metrics with a targetResponse rate (target 100%), review score (target 4.8)**Inverse**"Less is better" metrics with a cutoffHost cancellation rate, complaint ratio, refund rate**Tiered**Step-based achievementsListing photos, amenities count, completed stays### Combining Helpers

[](#combining-helpers)

You can use logic to pick the right helper within a single calculator:

```
class ListingDescriptionCalculator extends BaseCalculator
{
    public function calculate(Model $scoreable, int $maxPoints, array $taskMetadata = []): array
    {
        $description = $scoreable->description ?? '';
        $length = mb_strlen(strip_tags($description));

        // No description = binary fail
        if ($length === 0) {
            return $this->binaryScore(false, $maxPoints);
        }

        // Score based on description quality tiers
        return $this->tieredScore($length, [
            1   => 0.20,  // Has something — better than nothing
            50  => 0.40,  // Brief — covers the basics
            150 => 0.70,  // Detailed — mentions amenities & rules
            400 => 1.0,   // Comprehensive — neighborhood, tips, etc.
        ], $maxPoints);
    }
}
```

### Passing Metadata

[](#passing-metadata)

All helpers accept an optional `$metadata` array. Use it to store debug info, intermediate values, or display data:

```
return $this->proportionalScore($rate, $maxPoints, [
    'total_inquiries' => $total,
    'responded' => $responded,
    'response_rate' => 85.0,
]);
// Returns: ['score' => 85, 'metadata' => ['total_inquiries' => 20, 'responded' => 17, 'response_rate' => 85.0]]
```

Metadata is stored in the `model_scores_scores` table and accessible via `$host->scoreBreakdown()`.

---

Task Groups
-----------

[](#task-groups)

Organize tasks into logical groups:

```
use Aftandilmmd\LaravelModelScores\Models\ModelScoreTaskGroup;

$group = ModelScoreTaskGroup::create([
    'key' => 'profile',
    'name' => 'Profile Completion',
    'icon' => 'user',
    'order_column' => 1,
]);

ModelScoreTask::create([
    'key' => 'profile_photo',
    'name' => 'Upload Profile Photo',
    'group_id' => $group->id,
    'calculator' => ProfilePhotoCalculator::class,
    'type' => 'static',
    'max_points' => 50,
]);
```

---

Weighted Scoring
----------------

[](#weighted-scoring)

Give tasks different weights. Enable in config:

```
'features' => ['weights' => true],
```

```
ModelScoreTask::create([
    'key' => 'reviews',
    'name' => 'Customer Reviews',
    'calculator' => ReviewsCalculator::class,
    'type' => 'periodic',
    'max_points' => 100,
    'weight' => 1.50, // 1.5x multiplier → up to 150 points
]);
```

---

Score Decay
-----------

[](#score-decay)

Periodic task scores gradually decrease if not recalculated:

```
'features' => ['decay' => true],
'decay' => ['strategy' => 'linear'], // or 'exponential'
```

```
ModelScoreTask::create([
    'key' => 'returning_customers',
    'calculator' => ReturningCustomersCalculator::class,
    'type' => 'periodic',
    'max_points' => 100,
    'decay_days' => 30, // starts decaying after 30 days
]);
```

- **Linear**: 2% decrease per day after decay period
- **Exponential**: multiplied by 0.95 each day after decay period

---

Badge System
------------

[](#badge-system)

Define badges assigned automatically based on score ranges:

```
use Aftandilmmd\LaravelModelScores\Models\ModelScoreBadge;

ModelScoreBadge::create(['key' => 'bronze', 'name' => 'Bronze', 'min_score' => 100, 'max_score' => 299, 'color' => '#CD7F32']);
ModelScoreBadge::create(['key' => 'silver', 'name' => 'Silver', 'min_score' => 300, 'max_score' => 599, 'color' => '#C0C0C0']);
ModelScoreBadge::create(['key' => 'gold',   'name' => 'Gold',   'min_score' => 600, 'max_score' => null, 'color' => '#FFD700']);
```

`BadgeEarned` and `BadgeLost` events fire automatically when badge changes.

---

Score Profiles
--------------

[](#score-profiles)

Score a model on multiple dimensions independently:

```
ModelScoreTask::create([
    'key' => 'profile_photo',
    'calculator' => ProfilePhotoCalculator::class,
    'max_points' => 50,
    'type' => 'static',
    'profile' => 'quality',
]);

$tenant->calculateScore(profile: 'quality');
$badge = $tenant->scoreBadge(profile: 'quality');
```

---

Event Sourcing &amp; History
----------------------------

[](#event-sourcing--history)

Every score change is logged as an event record:

```
'features' => ['event_log' => true],
```

```
// Daily totals (for charts)
$history = ModelScore::getScoreHistory($tenant, days: 30);

// Full event timeline
$timeline = ModelScore::getScoreTimeline($tenant, limit: 50);

// Filter by event type
$timeline = ModelScore::getScoreTimeline($tenant, eventType: 'badge_earned');
```

Event types: `task_score_changed`, `adjustment_added`, `adjustment_expired`, `adjustment_revoked`, `badge_earned`, `badge_lost`, `recalculated`

---

Threshold Notifications
-----------------------

[](#threshold-notifications)

Fire events when scores cross configured thresholds:

```
'thresholds' => [
    'score_rose_above' => [500, 750, 900],
    'score_dropped_below' => [200, 100],
],
```

Listen to `ScoreThresholdCrossed`:

```
Event::listen(ScoreThresholdCrossed::class, function ($event) {
    // $event->scoreable, $event->threshold, $event->direction ('up' or 'down')
});
```

---

Events
------

[](#events)

EventWhen`ScoresCalculated`After all tasks are calculated for a model`TaskScoreUpdated`When a single task score changes`BadgeEarned`When score reaches a new badge level`BadgeLost`When score drops below a badge level`ScoreThresholdCrossed`When configured threshold is crossed`ManualAdjustmentApplied`When a manual adjustment is added---

Artisan Commands
----------------

[](#artisan-commands)

```
# Calculate scores for all models
php artisan model-scores:calculate "App\Models\Tenant"

# Calculate for a specific model
php artisan model-scores:calculate "App\Models\Tenant" --id=1

# Only static or periodic tasks
php artisan model-scores:calculate "App\Models\Tenant" --type=static

# Use a specific profile
php artisan model-scores:calculate "App\Models\Tenant" --profile=quality

# Prune old event logs
php artisan model-scores:prune-events --days=365
```

---

Livewire Components (Optional)
------------------------------

[](#livewire-components-optional)

Enable in config with `model-scores.livewire.enabled = true`:

```

```

[![Tasks List](screenshots/tasks-list.png)](screenshots/tasks-list.png)

---

REST API (Optional)
-------------------

[](#rest-api-optional)

Enable in config with `model-scores.api.enabled = true`:

MethodEndpointDescriptionGET`/api/model-scores/tasks`List all tasksGET`/api/model-scores/{type}/{id}/breakdown`Score breakdownGET`/api/model-scores/{type}/{id}/history`Score historyPOST`/api/model-scores/{type}/{id}/adjustments`Add adjustmentDELETE`/api/model-scores/adjustments/{id}`Revoke adjustment---

Configuration Reference
-----------------------

[](#configuration-reference)

All features can be toggled in `config/model-scores.php`:

```
'features' => [
    'badges' => true,
    'event_log' => true,
    'decay' => true,
    'adjustments' => true,
    'weights' => true,
    'groups' => true,
    'thresholds' => true,
],
```

KeyDefaultDescription`score_column``null`Column name on model for caching total score. `null` = compute from DB`decay.strategy``'linear'``'linear'` or `'exponential'``event_log.retention_days``365`Days to keep event logs`api.enabled``false`Enable REST API endpoints`livewire.enabled``true`Register Livewire components---

Database Tables
---------------

[](#database-tables)

The package creates 6 tables (all customizable via config):

TablePurpose`model_scores_task_groups`Task group definitions`model_scores_tasks`Task definitions with calculator FQCN`model_scores_scores`Per-model task score results`model_scores_score_events`Event-level score history`model_scores_badges`Badge/level definitions`model_scores_adjustments`Manual point adjustmentsLicense
-------

[](#license)

MIT License. See [LICENSE](LICENSE) for details.

###  Health Score

37

—

LowBetter than 83% of packages

Maintenance82

Actively maintained with recent releases

Popularity5

Limited adoption so far

Community6

Small or concentrated contributor base

Maturity48

Maturing project, gaining track record

 Bus Factor1

Top contributor holds 100% of commits — single point of failure

How is this calculated?**Maintenance (25%)** — Last commit recency, latest release date, and issue-to-star ratio. Uses a 2-year decay window.

**Popularity (30%)** — Total and monthly downloads, GitHub stars, and forks. Logarithmic scaling prevents top-heavy scores.

**Community (15%)** — Contributors, dependents, forks, watchers, and maintainers. Measures real ecosystem engagement.

**Maturity (30%)** — Project age, version count, PHP version support, and release stability.

###  Release Activity

Cadence

Every ~2 days

Total

3

Last Release

89d ago

### Community

Maintainers

![](https://www.gravatar.com/avatar/630ea45c41a7b5c54294a46920d0036cf0693865c7d3254c445b6cc08670edd0?d=identicon)[aftandilmmd](/maintainers/aftandilmmd)

---

Top Contributors

[![aftandilmmd](https://avatars.githubusercontent.com/u/26919900?v=4)](https://github.com/aftandilmmd "aftandilmmd (8 commits)")

---

Tags

laravelqualityRatingGamificationscore

### Embed Badge

![Health badge](/badges/aftandilmmd-laravel-model-scores/health.svg)

```
[![Health](https://phpackages.com/badges/aftandilmmd-laravel-model-scores/health.svg)](https://phpackages.com/packages/aftandilmmd-laravel-model-scores)
```

###  Alternatives

[barryvdh/laravel-ide-helper

Laravel IDE Helper, generates correct PHPDocs for all Facade classes, to improve auto-completion.

14.9k123.0M687](/packages/barryvdh-laravel-ide-helper)[willvincent/laravel-rateable

Allows multiple models to be rated with a fivestar like system.

416452.0k3](/packages/willvincent-laravel-rateable)[codebyray/laravel-review-rateable

Review &amp; Rating system for Laravel 10, 11 &amp; 12

310351.9k](/packages/codebyray-laravel-review-rateable)[ghanem/rating

Rating system for Laravel

8615.5k](/packages/ghanem-rating)[glhd/special

1929.4k](/packages/glhd-special)[bjuppa/laravel-blog

Add blog functionality to your Laravel project

483.3k2](/packages/bjuppa-laravel-blog)

PHPackages © 2026

[Directory](/)[Categories](/categories)[Trending](/trending)[Changelog](/changelog)[Analyze](/analyze)
