Weave Code
Weaver
Helps Laravel developers discover, compare, and choose open-source packages. See popularity, security, maintainers, and scores at a glance to make better decisions.
Feedback
Share your thoughts, report bugs, or suggest improvements.
Subject
Message

Level Up Laravel Package

cjmellor/level-up

Level Up is a Laravel gamification package for adding experience points, levels, achievements, and badges to your app. Award XP for user actions, define level thresholds, unlock achievements, and track progress with simple traits, models, and events.

View on GitHub
Deep Wiki
Context7

Getting Started

Install and publish

composer require cjmellor/level-up:^3.0
php artisan vendor:publish --tag="level-up-config"
php artisan vendor:publish --tag="level-up-migrations"
php artisan migrate

The migrate step matters more in v3 than it did in v2: migrate_multiplier_scopes_to_typed_pivots runs automatically and backfills any existing multiplier_scopes rows into the new multiplier_user / multiplier_tier pivots. On a fresh install it is a no-op. On an upgrade, take a backup and run it against a staging copy first — it is the one piece of v3 you cannot skip past.

Wire the traits onto your User model

use LevelUp\Experience\Concerns\HasAchievements;
use LevelUp\Experience\Concerns\HasChallenges;
use LevelUp\Experience\Concerns\HasLeagues;
use LevelUp\Experience\Concerns\HasLevels;
use LevelUp\Experience\Concerns\HasStreaks;

class User extends Authenticatable
{
    use HasLevels, HasAchievements, HasChallenges, HasStreaks, HasLeagues;
}

In v3 there are no aliasing helpers for colliding trait methods. If your User model already has challenges(), streaks(), experience(), or experienceHistory(), rename your method or move the gamification surface behind a wrapper/profile model. Decide this before you start, not after the first BadMethodCallException.

Use the Blueprint macro for foreign keys

Schema::create('badges', function (Blueprint $table) {
    $table->id();
    $table->userForeignId(); // foreignId / foreignUuid / foreignUlid, per config
});

The old LevelUp\Experience\Support\UserForeignKey helper is gone; this macro replaces it and reads the same config, so the FK type stays consistent with the package tables.

Orient yourself in config/level-up.php. In v3 these are the sections that actually drive behaviour:

Key What it does
audit.enabled Now defaults to true. Populates experience_audits, which time-windowed XP boards read from.
leaderboard.metrics Registry of RankingMetric implementations available to Leaderboard::by() / board().
leaderboard.boards Named metric/period(/tier) combinations. Only declared Boards are tracked over time.
leaderboard.snapshots.retention_days How many snapshot runs to keep.
leaderboard.week_starts_on, timezone Period boundary maths for Period::Week / Period::Month.
tables.experiences, tables.multiplier_user, tables.multiplier_tier Table names. The legacy level-up.table key is removed.

First use case that pays for itself: award points on a domain action, then put a weekly XP board behind it.

$user->addPoints(50); // fires PointsIncreased; level/tier recalculated

$entries = Leaderboard::board('weekly-xp')->generate();

foreach ($entries as $entry) {
    echo "{$entry->rank}. {$entry->user->name} — {$entry->score}";
}

Schedule the snapshot command so the board has history and rank-change events to fire:

// routes/console.php (or Kernel::schedule)
Schedule::command('level-up:snapshot-boards')->hourly();

Implementation Patterns

Points, levels, and tiers

Points mutations are now atomic and transaction-aware. addPoints(), deductPoints(), and setPoints() wrap their writes in DB::transaction(), and PointsIncreased, PointsDecreased, UserLevelledUp, UserTierUpdated, MultiplierApplied all implement ShouldDispatchAfterCommit.

This is a behavioural improvement you should lean on rather than fight:

DB::transaction(function () use ($user, $order) {
    $order->markPaid();
    $user->addPoints($order->xpValue()); // listener sees a committed row
});

Listeners firing after commit means side effects (mail, HTTP, non-ShouldQueueAfterCommit jobs) no longer fire on a transaction that rolls back. Use ShouldQueueAfterCommit on your own queued listeners so they inherit the same guarantee in the chain.

Two sharp edges:

  • setPoints() is no longer a raw column write. It recalculates level and tier and can fire UserLevelledUp / UserTierUpdated. Anything you used setPoints() for as a "silent correction" — importing legacy data, admin overrides — will now emit events and run listeners. If you want silence, gate your listeners or use a direct query builder update for backfills.
  • addPoints() no longer throws when the amount exceeds the highest level threshold; the user is capped at the top level. If you have a try/catch around addPoints() for that case, it is now dead code, and any code that relied on the exception to detect "over-award" needs to compare totals instead.

Achievements: percentage and absolute count

v3 adds an open-ended count alongside the percentage, which is what you want for "read 40 articles" style progression where the percentage is derived but the real number is displayed.

$user->grantAchievement($achievement);                    // sets/opens the count
$user->incrementAchievementProgress($achievement, 1);     // carries the increment on AchievementProgressionIncreased
$user->revokeAchievement($achievement);

The increment value rides on AchievementProgressionIncreased, so a single listener can drive both a progress bar and a "40/100" label without re-reading the model.

The cached achievements, allAchievements, achievementsWithProgress, and secretAchievements relations are now cleared by these three methods, so reads on the same instance are correct without refresh(). Other instances still hold stale relations — reload explicitly if you hand a User to a separate service after mutating achievements elsewhere.

Multipliers: typed scopes

Replace v2's polymorphic scopeTo() with explicit, intention-revealing calls:

$multiplier->scopeToUser($userA, $userB);
$multiplier->scopeToTier($goldTier);
$multiplier->unscopeFromUser($userA);
$multiplier->isGlobal(); // true when no user/tier scope attached

The MorphToManyWithTextCast Postgres workaround is gone, along with the MultiplierScope model and both config keys that pointed at it. Audit any custom code that resolved config('level-up.models.multiplier_scope') or joined multiplier_scopes directly — there is no shim, only the backfilled pivots.

Custom ranking metrics

Implement RankingMetric and register it, then use it directly or via a named Board:

// config/level-up.php
'leaderboard' => [
    'metrics' => [
        'xp'     => \LevelUp\Experience\Leaderboard\Metrics\ExperienceMetric::class,
        'level'  => \LevelUp\Experience\Leaderboard\Metrics\LevelMetric::class,
        'streak' => \LevelUp\Experience\Leaderboard\Metrics\StreakMetric::class,
    ],
    'boards' => [
        'weekly-xp' => ['metric' => 'xp', 'period' => 'week'],
        'all-time-streak' => ['metric' => 'streak', 'period' => null],
    ],
],

Built-in level and streak metrics are new — new StreakMetric(activity: $activity) scopes the streak metric to a specific Activity, which is the natural fit for "who's on the longest reading streak" style boards.

Windows, restriction, and per-scope ranking

Leaderboard::by('xp')->period(Period::Week)->generate();
Leaderboard::by('xp')->since(start: $from, until: $to)->generate();
Leaderboard::by('xp')->restrictTo($guildMemberIds)->generate(); // ranks computed within the set
Leaderboard::by('xp')->rankOf($user);
Leaderboard::by('xp')->around($user, radius: 3);

restrictTo() recomputes ranks within the restricted population — a user ranked 47 globally can be rank 2 on a friends board. Don't persist a rank without also persisting which board/restriction produced it.

rankOf() and around() are the two calls that make leaderboard UI tractable without generating the whole collection; prefer them over generate() + manual array_search.

Named boards and snapshots

Only Boards declared under leaderboard.boards are tracked over time. level-up:snapshot-boards persists each Board's top entries down to its Tracked Depth (track_top, default 100), diffs consecutive runs, and dispatches LeaderboardRankChanged, UserEnteredTrackedDepth, UserLeftTrackedDepth. snapshots.retention_days prunes old runs.

The intended pattern is: events out of snapshots, notifications and feeds in your listeners. Don't poll boards for rank changes; subscribe to LeaderboardRankChanged.

Because the snapshot run is where history is created, it is also where leaderboard_rank challenge progress happens. That condition validates against the Board's Tracked Depth at challenge creation time — see the warning below.

Leagues

$user->currentDivision();
$user->currentCohort();
$user->cohortStandings();

Leagues are a Division ladder with lazy Cohort enrollment on one periodic Board. Schedule the rollover:

Schedule::command('level-up:league-rollover')->weeklyOn(1, '00:05');

The rollover computes final standings live, promotes/relegates per the Division's configured counts, and dispatches UserDivisionChanged — that event is your hook for "you've been promoted" notices and rewards.

Testing

Use Event::fake() around points mutations and assert on the entries rather than on User attributes:

$entries = Leaderboard::board('weekly-xp')->generate();

$this->assertSame(1, $entries->first()->rank);
$this->assertSame(50, $entries->first()->score);

Because the package events are ShouldDispatchAfterCommit, assertDispatched inside a test wrapped in an outer transaction will not see them unless you use Event::fake() (which replaces the dispatcher before the commit check) or avoid wrapping. This is the single most common cause of "my listener works in prod, the test is green and wrong".

Gotchas and Tips

Leaderboard generation returns entries, not users. Leaderboard::generate() yields LeaderboardEntry objects: $entry->user, $entry->score, $entry->rank. Score and rank live on the entry and are never written back to the User model — the deliberate consequence being that anything reading $user->rank from v2 must be rewritten. Grep for generate() call sites before deploying.

Window functions are mandatory. Ranks use SQL window functions: MySQL 8+ or SQLite 3.25+ (MariaDB 10.2+ and PostgreSQL are fine natively). MySQL 5.7 is unsupported — if your CI matrix or a legacy read replica is still on 5.7, leaderboards will fail at query time, not at install time. Check DB::select('select version()') in staging, and make sure your test suite doesn't use an ancient bundled SQLite.

Auditing is on by default and periodic XP boards depend on it. experience_audits is the source for time-windowed XP leaderboards. Set AUDIT_POINTS=false and all-time boards keep working, but any periodic XP board throws MetricRequiresAuditingException. Decide per environment: opt out on high-write apps only if you don't use XP windows. The audit table grows with every points mutation — budget for pruning or partitioning.

Snapshot depth is a contract, not a knob. leaderboard_rank challenges are validated against the Board's Tracked Depth at creation. Lower track_top after challenges exist and previously-valid challenges reference a depth the board no longer snapshots. Treat track_top as effectively immutable, and raise it rather than lower it.

Named Board config is the entire tracking surface. A board you compute ad hoc with Leaderboard::by() but never declare under leaderboard.boards has no snapshots, no rank events, and no leaderboard_rank challenge progress. If a board should have history, declare it.

Trait collisions have no escape hatch. The aliasing helpers were removed. Colliding challenges() / streaks() / experience() / experienceHistory() on your User model must be renamed or composed into a wrapper. Verify on a branch before merging the upgrade — this fails loudly and late.

Stale config after publishing. The removed keys (level-up.table, models.multiplier_scope, tables.multiplier_scopes) will not error if left in your published config; they simply stop being read. If your FK column type or table name suddenly looks wrong, check whether you're relying on the dead key.

Period boundaries are configurable, so pin them. week_starts_on and timezone control Period::Week / Period::Month windows. Set both explicitly in config/level-up.php and in your test env — an unset timezone means a board that resets at the wrong hour for some of your users, and a test suite that passes locally and fails in CI.

Ties are competition-ranked with a deterministic tiebreak. You get 1, 1, 3 — not 1, 2, 3, and not 1, 1, 2. UI copy saying "top 10" should account for the possibility of more than 10 users holding ranks ≤ 10.

restrictTo() ranks are local. Never compare a restricted rank to a global rank or store both in the same column. Namespace cached ranks by board + restriction.

Postgres migration down() fix. The alter_experience_audits_type_to_string migration's down() now works on PostgreSQL (split ALTER COLUMN TYPE / ADD CONSTRAINT statements). If you previously avoided rolling back on pgsql, that workaround can go.

Debugging checklist for a board that "returns nothing":

  1. Is the metric registered in leaderboard.metrics? (MetricNotFoundException)
  2. Is it enabled? (MetricDisabledException)
  3. Is the board declared in leaderboard.boards? (no snapshots → nothing historical)
  4. Does the requested window need auditing, and is AUDIT_POINTS enabled? (MetricRequiresAuditingException)
  5. Has level-up:snapshot-boards actually run since the window opened?
  6. Does the DB support window functions? (silent-ish failures on MySQL 5.7)

Check those six in order — they cover essentially every "empty leaderboard" report.

Extension points worth knowing: implement RankingMetric for anything you can express in SQL; listen to LeaderboardRankChanged / UserEnteredTrackedDepth / UserLeftTrackedDepth instead of polling; use UserDivisionChanged for league comms; override level-up.leaderboard.metrics to swap in your own metric classes without touching package code.

Weaver

How can I help you explore Laravel packages today?

Conversation history is not saved when not logged in.
Prompt
Add packages to context
No packages found.
socialdept/tenant-domains
tautvydasr/blade-class-names
socialdept/color-scales
maherelgamil/controller
coleus/widgets
coleus/table
coleus/users
coleus/music
coleus/health
coleus/calendar
coleus/notes
coleus/support
coleus/settings
coleus/apps
seampact/laravel
emmyconceptng/laravel-ng-postcode
jjsquady/round-robin
skyraptor/chatter
edlugz/sasapay
abbasudo/artisan-route