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.
Package: cjmellor/level-up v3.0.0 — a Laravel gamification layer (experience points, levels, tiers, achievements, challenges, streaks) that now also ships a metric-driven leaderboard, snapshot engine, and leagues/divisions system.
Architecture fit
User model via traits. v3 keeps that surface but adds a read-side ranking engine — Leaderboard::by(), SQL window-function ranks, time-windowed scoring off an experience_audits ledger, periodically persisted snapshots, and cohort-based league standings.ShouldDispatchAfterCommit), so it fits an existing Laravel monolith or modular monolith cleanly. It is a poor fit for anything that cannot expose a schedulable process (multi-tenant queues per-tenant, or a stack where the User model is not the canonical actor).RankingMetric contracts and named Boards declared in config is a genuinely good extension point: TPM can scope product asks (e.g. "top 10 by weekly XP per guild") to config + a small custom metric class rather than a fork.Integration feasibility
multiplier_scopes polymorphic table replaced by typed multiplier_user / multiplier_tier pivots; MultiplierScope model and three config keys removed; legacy level-up.table key removed. Any host code reading those keys or joining the old table silently breaks.Multiplier::scopeTo() gone (typed replacements), UserForeignKey helper class gone (Blueprint macro instead), trait method aliasing helpers removed — host models with their own challenges() / streaks() / experience() / experienceHistory() methods will now collide and need wrapper models.Leaderboard::generate() returns LeaderboardEntry objects, not User collections. Any Blade view, API resource, or JSON contract consuming it breaks at runtime, not at install time.setPoints() now fires UserLevelledUp / UserTierUpdated; audit defaults to true; excess points cap at the top level instead of throwing. All three are silent semantic changes that can double-count rewards or mask misconfigured level thresholds.addPoints/deductPoints/setPoints, ShouldDispatchAfterCommit on all package events (eliminates phantom mail/HTTP on rolled-back transactions), relation-cache clearing after achievement mutations, the PostgreSQL down() fix, and removal of the v2.1 MorphToManyWithTextCast Postgres workaround.Technical risk
php artisan migrate. There is no documented batching, dry-run, or resumability. On a large multiplier_scopes table this is an unbounded production migration with no down() story for the pivots.around() and rankOf() imply per-user rank computation. Cost scales with user count, not board size, unless indexes and materialisation are right.level-up:snapshot-boards and level-up:league-rollover are now correctness-critical scheduled jobs. Missed runs mean missing rank events, wrong promotions/relegations, and broken leaderboard_rank challenge progress.audit.enabled now defaults to true because periodic XP boards read from experience_audits. That is a new always-on high-volume table. Retention policy for audits is not described (snapshots have retention_days; audits appear to be yours to manage). Audit rows are effectively a per-user activity log — GDPR erasure and PII review are required.Key questions
multiplier_scopes and experiences today, plus 12-month projections — what is the expected migration duration and index footprint?User models define challenges(), streaks(), experience(), or experienceHistory()? How many call sites consume Leaderboard::generate()?snapshot-boards or league-rollover run?experience_audits, and does it fall under an existing data-retention schedule?restrictTo() covers population restriction, but tenant isolation is a design decision we must make explicitly.Stack fit
User model and a queue + scheduler already in place. The RankingMetric contract and config-declared Boards mean most product variants are configuration, not custom code.ShouldDispatchAfterCommit) make this materially safer to bolt onto existing transactional flows (orders, subscriptions, check-ins) than v2 was.Migration path
composer update side effect.level-up.tables.experiences, tables.multiplier_user / multiplier_tier), removed model/config keys, UserForeignKey → $table->userForeignId(), scopeTo() → typed methods.Leaderboard::generate() and of any points/level change path, especially anywhere setPoints() is used as a raw write (those now emit events).multiplier_scopes and the two new pivots. This is the gate, not the code review.Compatibility
down() paths, not just up(). The PostgreSQL down() fix in this release signals that rollback paths have historically been under-tested; assume others are too.ALTER COLUMN TYPE) that have bitten this package before.setPoints() side effects, and audit-on-by-default are all changes a green test suite can miss.addPoints() first-time-experience branch and PointsIncreasedListener agree on starting level in our data (thresholds are not guaranteed monotonic in our fixtures).Sequencing (recommended release train)
generate() consumers updated. Feature parity with v2 behaviour; no leaderboards or leagues surfaced yet.experience_audits volume and retention job. Do not enable any periodic board before this is stable.restrictTo() populations. Ship rankOf()/around() UI only after query-cost validation.level-up:snapshot-boards, wire LeaderboardRankChanged / UserEnteredTrackedDepth / UserLeftTrackedDepth to notifications. Set track_top deliberately — it drives both snapshot write volume and the leaderboard_rank challenge's valid depth.level-up:league-rollover, UserDivisionChanged. Ship last; it is the highest-consequence automation and depends on R3/R4 being trustworthy.count (grantAchievement() + incrementAchievementProgress()) where the product wants "12 of 50 articles" style framing. Cheapest, most isolated addition — safe to pull forward if roadmap demands.Maintenance
MorphToManyWithTextCast Postgres workaround, no polymorphic scope table, one canonical way to express a multiplier scope.userForeignId() Blueprint macro is a net win: it centralises UUID/ULID/int user key handling instead of duplicating a helper class per app.Support
MetricNotFoundException, MetricDisabledException, MetricRequiresAuditingException), which is good for debuggability but means misconfiguration reaches production as a thrown exception on a user-facing page. Wrap leaderboard endpoints with graceful degradation.Scaling
experience_audits write volume, always-on with audit defaulting to true; (b) window-function rank query cost, driven by user count and index quality; (c) snapshot persistence, driven by number of declared Boards × track_top × snapshot frequency × retention_days.track_top default of 100 is a lever with two effects: it caps snapshot write amplification but also caps the depth at which leaderboard_rank challenges can validate. Set it from product intent, not default.Failure modes and mitigations
setPoints() now emits level/tier events → duplicate reward grants if downstream listeners already applied rewards manually. Audit every setPoints() caller before cutover.AUDIT_POINTS=false with a periodic XP board → MetricRequiresAuditingException in production. Codify the pairing as a config test.snapshot-boards or league-rollover run → stale ranks, missing events, wrong promotions. Alert on absence, not just on failure; make commands idempotent-safe and re-runnable.generate() returns entries, not users → contract break in views/API. Update serializers and add contract tests.Ramp-up
track_top per board rather than letting engineers pick defaults.experience_audits retention, dashboard the snapshot job's health, and decide whether rank history feeds analytics or stays in-app.How can I help you explore Laravel packages today?