| name | level-up-development |
| description | Build and work with cjmellor/level-up features, including XP, levels, tiers, achievements, streaks, multipliers, challenges, leaderboards, leagues, and auditing. |
When to use this skill
Use this skill when working with gamification features — adding experience points, levels, tiers, achievements, streaks, multipliers, leaderboards, leagues, or auditing — using cjmellor/level-up.
Core Concepts
- XP and Levels — Users earn experience points (XP) and automatically progress through a defined level structure.
- Tiers — Named status brackets (e.g. Bronze, Silver, Gold) based on XP thresholds, independent of numeric levels.
- Achievements — Unlockable rewards, optionally with progress tracking, optionally gated by tier.
- Streaks — Track consecutive daily activities with freeze support.
- Multipliers — Database-backed point modifiers with scoping, scheduling, and configurable stacking strategies.
- Leaderboard — Rank users by any metric (XP by default) with competition-style rank numbers, optionally scoped to a tier or a time period (day/week/month/custom).
- Leagues — A competitive cycle on one periodic Board: active users are grouped into small Cohorts within a Division each period and ranked within their Cohort. A Division is competition history, NOT a Tier (which is XP status) — a user holds both independently.
- Auditing — Automatic history of all XP changes, level-ups, and tier changes.
Setup
Install
composer require cjmellor/level-up
php artisan vendor:publish --tag="level-up-migrations"
php artisan migrate
php artisan vendor:publish --tag="level-up-config"
Add Traits to the User Model
Add only the traits you need:
use LevelUp\Experience\Concerns\GiveExperience;
use LevelUp\Experience\Concerns\HasAchievements;
use LevelUp\Experience\Concerns\HasChallenges;
use LevelUp\Experience\Concerns\HasLeagues;
use LevelUp\Experience\Concerns\HasStreaks;
use LevelUp\Experience\Concerns\HasTiers;
class User extends Authenticatable
{
use GiveExperience;
use HasAchievements;
use HasStreaks;
use HasTiers;
use HasChallenges;
use HasLeagues;
}
GiveExperience is the foundation. The others are opt-in.
Levels
Define Levels
use LevelUp\Experience\Models\Level;
Level::add(
['level' => 1, 'next_level_experience' => null],
['level' => 2, 'next_level_experience' => 100],
['level' => 3, 'next_level_experience' => 250],
['level' => 4, 'next_level_experience' => 500],
['level' => 5, 'next_level_experience' => 1000],
);
Level 1 must have next_level_experience set to null — it is the default starting point. Users level up automatically when their XP reaches the threshold. Throws LevelExistsException if a level number already exists.
Level Queries
$user->getLevel();
$user->getPoints();
$user->nextLevelAt();
$user->nextLevelAt(checkAgainst: 5);
$user->nextLevelAt(showAsPercentage: true);
Manual Level Up
$user->levelUp(to: 5);
Throws InvalidArgumentException if the level does not exist. Fires UserLevelledUp for each intermediate level gained. Respects the level cap.
Level Cap
Configured in config/level-up.php:
'level_cap' => [
'enabled' => env('LEVEL_CAP_ENABLED', true),
'level' => env('LEVEL_CAP', 100),
'points_continue' => env('LEVEL_CAP_POINTS_CONTINUE', true),
],
When the cap is reached, the user stops levelling. If points_continue is true, XP still accumulates. If false, XP stops accumulating too.
Experience Points (XP)
Add Points
$user->addPoints(50);
$user->addPoints(50, reason: 'Completed tutorial');
$user->addPoints(50, multiplier: 2);
$user->addPoints(50, type: AuditType::Add->value, reason: 'Bonus');
Creates an experience record if none exists, otherwise increments. Automatically levels up if the threshold is crossed. Points past the highest level's next_level_experience cap the user at the top level (no exception).
Deduct Points
$user->deductPoints(30);
$user->deductPoints(30, reason: 'Penalty');
Throws Exception if the user has no experience record.
Set Points
$user->setPoints(500);
Directly overwrites the XP total, then recalculates level and tier from the new total (firing UserLevelledUp / UserTierUpdated as needed). Throws Exception if the user has no experience record. Writes no audit record — it is an administrative override, invisible to periodic leaderboards.
Get Points
$user->getPoints();
Multipliers
Multipliers are database-backed records that modify point calculations. They can be scoped to specific users or tiers, scheduled with time windows, and configured to stack using different strategies.
Create Multipliers
use LevelUp\Experience\Models\Multiplier;
Multiplier::create([
'name' => 'Weekend Bonus',
'multiplier' => 2.0,
'is_active' => true,
'starts_at' => now()->startOfWeekend(),
'expires_at' => now()->endOfWeekend(),
]);
Active multipliers are automatically applied when addPoints() is called. The multiplier value must be at least 0.01. If both starts_at and expires_at are set, starts_at must be before expires_at.
Scope Multipliers
Multipliers with no scopes apply to all users. Use the typed scopeToUser / scopeToTier methods to restrict:
$multiplier->scopeToUser($user);
$multiplier->scopeToTier($goldTier);
$multiplier->scopeToUser($user)->scopeToTier($tier);
$multiplier->scopeToUser($alice, $bob, $carol);
$multiplier->scopeToTier($silver, $gold, $platinum);
$multiplier->unscopeFromUser($user);
$multiplier->unscopeFromTier($goldTier);
$multiplier->isGlobal();
scopeToUser and scopeToTier are idempotent (they use syncWithoutDetaching internally) — calling them twice with the same model does not create duplicates.
Inline Multiplier
$user->addPoints(amount: 10, multiplier: 3);
Inline multipliers stack with DB multipliers according to the configured strategy.
Stacking Strategies
Configure in config/level-up.php:
'multiplier' => [
'enabled' => env('MULTIPLIER_ENABLED', true),
'stack_strategy' => env('MULTIPLIER_STACK', 'compound'),
],
Query Multipliers
Multiplier::active()->get();
Multiplier::active()->forUser($user)->get();
Multiplier::scheduled()->get();
Multiplier::expired()->get();
MultiplierApplied Event
Fires when multipliers modify a point calculation. Properties: Model $user, Collection $multipliers, int $originalAmount, int $finalAmount, string $strategy.
Tiers
Define Tiers
use LevelUp\Experience\Models\Tier;
Tier::add(
['name' => 'Bronze', 'experience' => 0],
['name' => 'Silver', 'experience' => 500],
['name' => 'Gold', 'experience' => 2000],
['name' => 'Platinum', 'experience' => 5000, 'metadata' => ['color' => '#E5E4E2', 'icon' => 'crown']],
);
Tier names and experience values must be unique. Throws TierExistsException on duplicates. The metadata column is a flexible JSON field for any extra data (colours, icons, descriptions). The entire add() call is wrapped in a database transaction — if any tier fails, none are created.
Automatic Tier Promotion
Tiers update automatically when XP changes. When addPoints() causes the user to cross a tier threshold, experience.tier_id is updated and a UserTierUpdated event fires with TierDirection::Promoted.
Query Tiers
$user->getTier();
$user->getNextTier();
$user->tierProgress();
$user->nextTierAt();
$user->isAtTier('Gold');
$user->isAtOrAboveTier('Silver');
getTier() returns null if the user has no experience record or tiers are disabled.
Demotion
By default, tiers use a high-water mark — once earned, they persist even if points decrease. Enable demotion to allow tier drops:
TIER_DEMOTION=true
When enabled, deductPoints() checks if the user should drop and fires UserTierUpdated with TierDirection::Demoted. The newTier property is nullable — it will be null if the user drops below all tier thresholds.
Tier-Scoped Multipliers
Create a multiplier and scope it to a tier so it only applies to users at that tier:
$multiplier = Multiplier::create([
'name' => 'Gold Bonus',
'multiplier' => 2.0,
'is_active' => true,
]);
$goldTier = Tier::where('name', 'Gold')->first();
$multiplier->scopeToTier($goldTier);
When a Gold-tier user calls addPoints(), this multiplier is automatically included.
Tier-Gated Achievements
Restrict achievements so only users at a certain tier can earn them:
$goldTier = Tier::where('name', 'Gold')->first();
Achievement::create([
'name' => 'Golden Streak',
'tier_id' => $goldTier->id,
]);
Attempting to grant to a user below Gold throws TierRequirementNotMet.
Tier-Scaled Streak Freezes
Higher tiers get longer freeze durations:
'tiers' => [
'streak_freeze_days' => [
'Bronze' => 1,
'Silver' => 2,
'Gold' => 3,
'Platinum' => 7,
],
],
Falls back to the global freeze_duration if the tier is not listed or tiers are disabled.
Tier-Scoped Leaderboards
use LevelUp\Experience\Facades\Leaderboard;
Leaderboard::forTier('Gold')->generate();
Leaderboard::forTier($tierModel)->generate();
Tier Config
'tiers' => [
'enabled' => env('TIERS_ENABLED', true),
'demotion' => env('TIER_DEMOTION', false),
'streak_freeze_days' => [],
],
Achievements
Create Achievements
use LevelUp\Experience\Models\Achievement;
Achievement::create([
'name' => 'First Login',
'is_secret' => false,
'description' => 'Log in for the first time',
'image' => 'storage/app/achievements/first-login.png',
]);
Achievement::create([
'name' => 'Hidden Gem',
'is_secret' => true,
]);
Achievement::create([
'name' => 'Gold Member Badge',
'tier_id' => $goldTier->id,
]);
Grant Achievement
$user->grantAchievement($achievement);
$user->grantAchievement($achievement, progress: 50);
Throws Exception if progress exceeds 100, or if the user already has the achievement. Throws TierRequirementNotMet if tier-gated and user does not meet the tier requirement. AchievementAwarded event fires only when progress is null or 100.
Revoke Achievement
$user->revokeAchievement($achievement);
Throws Exception if the user does not have the achievement.
Achievement Progress
$newProgress = $user->incrementAchievementProgress($achievement, amount: 10);
$user->achievementsWithProgress()->get();
$user->achievementsWithSpecificProgress(75)->get();
incrementAchievementProgress() throws Exception if the user does not have the achievement. Grant it first. Progress is capped at 100.
Query Achievements
$user->achievements;
$user->secretAchievements;
$user->allAchievements;
$user->getUserAchievements();
Streaks
Create Activities
use LevelUp\Experience\Models\Activity;
Activity::create(['name' => 'daily-login', 'description' => 'User logs in']);
Record a Streak
$activity = Activity::where('name', 'daily-login')->first();
$user->recordStreak($activity);
- First call: creates streak (count = 1), fires
StreakStarted
- Same day: no-op
- Next consecutive day: increments count, fires
StreakIncreased
- Skipped a day: resets to 1, fires
StreakBroken (archives if enabled)
- Streak frozen: no-op until freeze expires
Query Streaks
$user->getCurrentStreakCount($activity);
$user->hasStreakToday($activity);
$user->streaks;
Reset / Freeze / Unfreeze
$user->resetStreak($activity);
$user->freezeStreak($activity);
$user->freezeStreak($activity, days: 5);
$user->unFreezeStreak($activity);
$user->isStreakFrozen($activity);
Streak History
When a streak breaks, it is archived automatically (if enabled):
use LevelUp\Experience\Models\StreakHistory;
$histories = StreakHistory::where('user_id', $user->id)->get();
Streak Config
'archive_streak_history' => [
'enabled' => env('ARCHIVE_STREAK_HISTORY_ENABLED', true),
],
'freeze_duration' => env('STREAK_FREEZE_DURATION', 1),
Challenges
Challenges are multi-condition goals that users enroll in and complete for rewards. Conditions are evaluated automatically when relevant events fire (points earned, level reached, achievement granted, streak recorded, tier changed, leaderboard rank moved by a snapshot run).
Create a Challenge
use LevelUp\Experience\Models\Challenge;
Challenge::create([
'name' => 'Getting Started',
'conditions' => [
['type' => 'points_earned', 'amount' => 100],
['type' => 'level_reached', 'level' => 3],
],
'rewards' => [
['type' => 'points', 'amount' => 50],
],
'auto_enroll' => true,
'is_repeatable' => false,
]);
Condition Types
| Type | Required Keys | What it checks |
|---|
points_earned | amount | Points earned since enrollment (baseline delta) |
level_reached | level | User's current level >= value |
achievement_earned | achievement_id | User has the achievement |
streak_count | activity, count | Current streak count for activity >= value |
tier_reached | tier | User is at or above the named tier |
leaderboard_rank | board, rank | Latest snapshot rank on the named Board <= value |
custom | class | Class implementing ChallengeCondition interface |
leaderboard_rank only progresses when level-up:snapshot-boards runs — the host must schedule it. Validation at creation rejects a board not declared in level-up.leaderboard.boards and a rank deeper than the Board's tracked depth (track_top, default 100).
Reward Types
| Type | Required Keys | What it does |
|---|
points | amount | Awards XP via addPoints() |
achievement | achievement_id | Grants the achievement |
Enrollment
$user->enrollInChallenge($challenge);
$user->unenrollFromChallenge($challenge);
Throws if: challenge not started yet, expired, already enrolled, or completed and not repeatable. Completed repeatable challenges can be re-enrolled.
Auto-enroll challenges (auto_enroll: true) automatically enroll users when a relevant event fires.
Query Progress
$user->getChallengeProgress($challenge);
$user->getChallengeCompletionPercentage($challenge);
$user->activeChallenges;
$user->completedChallenges;
$user->challengeCompletions;
Completion Ledger
Every completion — repeatable or not — writes a row to the challenge_completions table (model models.challenge_completion), recorded by ChallengeService::completeChallenge(). This ledger is the source of truth for the challenges leaderboard metric, so a repeatable challenge completed N times counts N. $user->challengeCompletions is the raw feed (one row per completion); $user->completedChallenges stays distinct via whereHas('completions'), so a challenge appears there once no matter how many times it's repeated. The migration backfills one row per already-completed challenge on upgrade.
Temporal Constraints
Challenges support optional starts_at and expires_at fields. If both are set, starts_at must be before expires_at. Expired challenges are not evaluated.
Custom Conditions
Implement the ChallengeCondition interface:
use LevelUp\Experience\Contracts\ChallengeCondition;
use Illuminate\Database\Eloquent\Model;
class HasVerifiedEmail implements ChallengeCondition
{
public function check(Model $user, array $condition): bool
{
return $user->hasVerifiedEmail();
}
}
Reference it in the condition: ['type' => 'custom', 'class' => HasVerifiedEmail::class].
Challenge Config
'challenges' => [
'enabled' => env('CHALLENGES_ENABLED', true),
],
Leaderboard
use LevelUp\Experience\Enums\Period;
use LevelUp\Experience\Facades\Leaderboard;
use LevelUp\Experience\Metrics\StreakMetric;
Leaderboard::generate();
Leaderboard::generate(paginate: true);
Leaderboard::generate(limit: 10);
Leaderboard::by('xp')->generate();
Leaderboard::by('level')->generate();
Leaderboard::by(new StreakMetric(activity: $activity))->generate();
Leaderboard::by('achievements')->();
::()->(::)->();
::(::)->();
::()->();
::(fn () => ->(, ))->();
::(::)->();
::(: ()->())->();
::()->();
::(: );
::(: , : );
Returns LeaderboardEntry objects — $entry->user (with experience eager-loaded), $entry->score, and $entry->rank — ordered by score descending, then user key ascending. Ranks use competition semantics (tied scores share a rank; the next rank is skipped: 1, 1, 3) and are board-wide even when limiting or paginating. rankOf() and around() compose with by(), period(), since(), and restrictTo(). restrictTo(Closure) is the seam for host-defined populations the package can't know about (friends boards, guilds, tournament brackets): the closure narrows the base user query (fn ($query) => $query->whereIn('id', $friendIds)), and ranks are computed within the restricted set — rank 1 among friends, not the global rank filtered down. Like the other fluent state it is consumed by the terminal call, so the next board is global again. Ranks are computed with SQL window functions (requires SQLite 3.25+, MySQL 8+ / MariaDB 10.2+, or PostgreSQL). Metrics are registered in level-up.leaderboard.metrics (custom ones implement LevelUp\Experience\Contracts\RankingMetric); unknown keys throw MetricNotFoundException, disabled-feature metrics throw MetricDisabledException.
Built-in metrics: xp (experience points, the default), level (current level), streak (current streak count for an Activity), achievements (achievements earned), and challenges (challenges completed). level and streak are state metrics — they rank by a current snapshot, and users without the relevant record are absent from the board. The streak metric requires an Activity: construct the instance (new StreakMetric(activity: $activity)); using the bare streak registry key without one throws MetricRequiresActivityException.
achievements and challenges are flow metrics (Windowable). achievements counts all earned achievements including secret ones — a count reveals nothing about which were earned; windowed boards count achievements earned within the period (pivot created_at). challenges counts completion rows in the challenge_completions ledger (one row per completion, so a repeatable challenge counts each time it's completed, not just once); windowed boards window on the ledger's completed_at. The challenges metric throws MetricDisabledException when level-up.challenges.enabled is off. For every metric, zero-count users are absent from the board, never ranked at 0.
Time Periods
period(Period::Day|Week|Month) and since(start:, until:) window a board to activity inside the range. For xp the windowed score is computed from the experience_audits ledger as add rows minus remove rows (state-change rows — reset, level_up, tier_up, tier_down — never count); this requires auditing (the v3 default), and with auditing explicitly disabled a periodic XP board throws MetricRequiresAuditingException. achievements and challenges window on their own timestamps (the achievement pivot's created_at and the challenge_completions ledger's completed_at) and don't need auditing. Only Windowable metrics support periods — xp, achievements, and challenges do; level and streak throw MetricNotWindowableException. Custom metrics opt in by implementing LevelUp\Experience\Contracts\Windowable (windowedScoreExpression($start, $end); $end is null for an open-ended since()).
setPoints() writes no audit record, so it never moves a periodic board (administrative override, not earned activity) — the all-time board sees it immediately. Users with no qualifying audit rows in the window are absent from the board. All-time boards (no period) read experiences.experience_points directly and never scan the ledger.
Period boundary config under level-up.leaderboard: week_starts_on (Carbon day-of-week, default CarbonInterface::MONDAY) and timezone (default null = app timezone) control where day/week/month boundaries fall.
Named Boards
A Board is a declared leaderboard — a named metric/period(/tier) combination registered in config — as opposed to an ad-hoc fluent query (composed, executed, forgotten). Only declared Boards are tracked over time (snapshots, rank events, leagues); declaring none means none of that machinery activates.
'leaderboard' => [
'boards' => [
'weekly-xp' => ['metric' => 'xp', 'period' => 'week'],
'gold-race' => ['metric' => 'xp', 'period' => 'week', 'tier' => 'Gold', 'track_top' => 50],
],
],
metric is required (a level-up.leaderboard.metrics registry key), period is optional ('day'/'week'/'month' — the Period enum string values), tier is optional (a tier name), track_top is optional (the tracked depth — how many top entries are snapshotted and evented, default 100). Leaderboard::board('weekly-xp') resolves the declaration into the same fluent query, so all refinements (restrictTo(), rankOf(), around(), limit:, paginate:) compose on top. Resolution validates loudly: unknown board name → BoardNotFoundException; missing or unknown metric → MetricNotFoundException; period on a non-Windowable metric → MetricNotWindowableException; invalid period string → ValueError; nonexistent tier name → ModelNotFoundException.
Snapshots and rank events
A Snapshot persists a Board's top track_top entries at a point in time (the leaderboard_snapshots table, LeaderboardSnapshot model: board, user_id, rank, score, run_at). The level-up:snapshot-boards command snapshots every declared Board, diffs against that Board's previous run, dispatches rank events, and prunes runs older than level-up.leaderboard.snapshots.retention_days (default 30). The host schedules it (e.g. Schedule::command('level-up:snapshot-boards')->hourly() in routes/console.php) — the package never auto-registers scheduler entries.
Diff semantics: rank movement within the tracked depth → LeaderboardRankChanged; crossing the boundary → UserEnteredTrackedDepth / UserLeftTrackedDepth; below the tracked depth a Board is silent by design (no rows, no events — don't "fix" this). The first run of a Board is silent (no previous run, no delta). A re-run within the same instant replaces that run's rows (a run is identified by run_at to the second) and recomputes the same diff. Snapshots are not a cache — rankOf()/around() always compute fresh at any depth.
Leagues
A League is a competitive cycle on one periodic Board: users are grouped into small Cohorts within a Division each Period, ranked within their Cohort. Declared under level-up.leaderboard.league:
'league' => [
'board' => 'weekly-xp',
'cohort_size' => 30,
'divisions' => [
'Bronze' => ['promote' => 10, 'relegate' => 0],
'Silver' => ['promote' => 7, 'relegate' => 5],
'Gold' => ['promote' => 0, 'relegate' => 5],
],
],
promote/relegate counts are consumed by the period rollover (below). Validation is loud, on first enrollment: undeclared board → BoardNotFoundException; board without a period → LeagueBoardNotPeriodicException; empty divisions → LeagueDivisionsNotDeclaredException.
A Division is NOT a Tier. Tier = pure function of current XP (status). Division = path-dependent competition history (held via cohort placement). A user holds a Tier and competes in a Division simultaneously and independently — HasTiers, experiences.tier_id, and tier events are untouched by leagues. Never conflate the two ladders.
Lazy enrollment (do not "fix" this): a user joins the current period's league on their first score-earning action (PointsIncreased listener), entering the open cohort of their Division; cohorts fill in arrival order and a new one opens at cohort_size. Ghosts (no qualifying activity in the period) are never cohorted and their Division carries over. New users enter the bottom Division; returning users re-enter the Division they held. Cohort sizes vary — the last cohort of a period may be small. No skill matching. Division rows (divisions table) are seeded from config on first need; cohorts live in cohorts + cohort_user.
$user->currentDivision();
$user->currentCohort();
$user->cohortStandings();
cohortStandings() runs the league Board restricted to the cohort's members — rank 1 is first in the cohort, not globally. Empty collection when not cohorted or no league configured. Requires the HasLeagues trait.
Rollover (level-up:league-rollover): host-scheduled just after the period boundary (e.g. Schedule::command('level-up:league-rollover')->weeklyOn(1, '00:05') for a Monday-start weekly league) — never auto-registered. For each cohort of the closed period it computes final standings live (not from snapshots) and moves users: top promote finishers up one Division, bottom relegate down one, rest stay. Semantics: the top Division never promotes and the bottom never relegates regardless of config; promoted = min(promote, cohort size) so a tiny cohort promotes everyone; promotion wins when promote and relegate slices overlap; ties straddling a boundary split by standings order (score, then user key) — not by rank number; ghosts (never cohorted that period) keep their Division, no event. Idempotent via the cohorts.rolled_over_at stamp — re-runs are no-ops. Movement is recorded as cohort_user.next_division_id and lands at the user's next-period enrollment (rollover enrolls nobody); currentDivision() reflects it immediately. Each movement fires one UserDivisionChanged (direction enum DivisionDirection::Promoted/Relegated, mirroring the tier event grammar — no separate promoted/relegated event classes).
Auditing
Enabled by default since v3 (periodic leaderboards source scores from the ledger):
'audit' => [
'enabled' => env('AUDIT_POINTS', true),
],
When enabled, every addPoints(), deductPoints(), levelUp(), and tier change creates an experience_audits record. setPoints() writes no audit record.
$user->experienceHistory;
Audit types use the AuditType enum:
use LevelUp\Experience\Enums\AuditType;
AuditType::Add;
AuditType::Remove;
AuditType::Reset;
AuditType::LevelUp;
AuditType::TierUp;
AuditType::TierDown;
Events
| Event | Properties | When |
|---|
PointsIncreased | int $pointsAdded, int $totalPoints, string $type, ?string $reason, Model $user, ?array $multipliers | Points added |
PointsDecreased | int $pointsDecreasedBy, int $totalPoints, ?string $reason, Model $user | Points deducted |
MultiplierApplied | Model $user, Collection $multipliers, int $originalAmount, int $finalAmount, string $strategy | Multipliers modified point calculation |
UserLevelledUp | Model $user, int $level | Level gained (fires per level) |
UserTierUpdated | Model $user, ?Tier $previousTier, ?Tier $newTier, TierDirection $direction | Tier promotion or demotion |
AchievementAwarded | Achievement $achievement, Model $user | Achievement granted at 100% |
AchievementRevoked | Achievement $achievement, Model $user | Achievement revoked |
AchievementProgressionIncreased | Achievement $achievement, Model $user, int $amount | Progress incremented |
StreakStarted | Model $user, Activity $activity, Streak $streak | First streak record |
StreakIncreased | Model $user, Activity $activity, |
Common Patterns
User Profile with Level and Tier
$user = User::with(['experience.status', 'experience.tier'])->find($id);
$data = [
'level' => $user->getLevel(),
'points' => $user->getPoints(),
'next_level_in' => $user->nextLevelAt(),
'level_progress' => $user->nextLevelAt(showAsPercentage: true),
'tier' => $user->getTier()?->name,
'tier_progress' => $user->tierProgress(),
'next_tier_in' => $user->nextTierAt(),
];
Level-Up Reward via Event
use LevelUp\Experience\Events\UserLevelledUp;
Event::listen(UserLevelledUp::class, function (UserLevelledUp $event) {
if ($event->level === 10) {
$achievement = Achievement::where('name', 'Hit Level 10')->first();
if ($achievement) {
$event->user->grantAchievement($achievement);
}
}
});
Seeding Levels and Tiers
class GamificationSeeder extends Seeder
{
public function run(): void
{
Level::add(
['level' => 1, 'next_level_experience' => null],
['level' => 2, 'next_level_experience' => 100],
['level' => 3, 'next_level_experience' => 250],
);
Tier::add(
['name' => 'Bronze', 'experience' => 0],
['name' => 'Silver', 'experience' => 500],
['name' => 'Gold', 'experience' => 2000],
);
Activity::create(['name' => 'daily-login']);
}
}
Config Reference
All model classes in the models config array can be overridden to use custom models. The user.foreign_key defaults to user_id and can be customised for non-standard setups.
return [
'models' => [
'achievement' => LevelUp\Experience\Models\Achievement::class,
'activity' => LevelUp\Experience\Models\Activity::class,
'experience' => LevelUp\Experience\Models\Experience::class,
'experience_audit' => LevelUp\Experience\Models\ExperienceAudit::class,
'level' => LevelUp\Experience\Models\Level::class,
'streak' => LevelUp\Experience\Models\Streak::class,
'streak_history' => LevelUp\Experience\Models\StreakHistory::class,
'achievement_user' => LevelUp\Experience\Models\Pivots\AchievementUser::class,
'tier' => LevelUp\Experience\Models\Tier::class,
'multiplier' => LevelUp\Experience\Models\Multiplier::class,
'multiplier_user' => LevelUp\Experience\Models\Pivots\MultiplierUser::class,
'multiplier_tier' => LevelUp\Experience\Models\Pivots\MultiplierTier::class,
'challenge' => LevelUp\Experience\Models\Challenge::class,
'challenge_user' => ::,
=> ::,
=> ::,
=> ::,
=> ::,
=> ::,
],
=> [
=> ,
=> ::,
=> ,
],
=> [
=> ,
=> [
=> ::,
=> ::,
=> ::,
=> ::,
=> ::,
],
=> [],
=> [
=> ,
],
=> [
=> ,
=> ,
=> [],
],
=> ::,
=> ,
],
=> ,
=> [
=> (, ),
=> (, ),
],
=> [
=> (, ),
=> (, ),
=> (, ),
],
=> [
=> (, ),
],
=> [
=> (, ),
],
=> (, ),
=> [
=> (, ),
=> (, ),
=> [],
],
=> [
=> (, ),
],
];