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-upgrade-v3
level-up-upgrade-v3
cjmellor/level-up
claude-code
level-up
upgrade
leaderboards
migrations
multipliers
challenges
database-version

Automatically upgrade cjmellor/level-up from v2.x to v3.0, applying all breaking changes to the user's codebase.

Install
php artisan boost:add-skill cjmellor/level-up

Save this content to: .claude/skills/level-up-upgrade-v3/SKILL.md

## When to use this skill

Use this skill when a user needs to upgrade from cjmellor/level-up v2.x to v3.0.

If the user is on v1.x, run the `level-up-upgrade-v2` skill first, then come back here.

## What's in v3

The headline is a metric-driven leaderboard: rank by any metric (`xp`, `level`, `streak`, `achievements`, `challenges`, or a custom `RankingMetric`), time Periods, `rankOf()`/`around()`, `restrictTo()` for friends boards, named Boards declared in config, Snapshots with rank-change events, a `leaderboard_rank` challenge condition, and Leagues (a Division ladder with Cohorts, promotion, and relegation). Three leaderboard breaking changes ride along:

- `Leaderboard::generate()` returns `LeaderboardEntry` objects (`$entry->user`, `$entry->score`, `$entry->rank`) instead of a bare collection of `User` models.
- `level-up.audit.enabled` defaults to `true` (was `false`) — periodic leaderboards source their scores from the `experience_audits` ledger.
- Leaderboard ranks are computed with SQL window functions, requiring SQLite 3.25+ or MySQL 8+ (MariaDB 10.2+ and PostgreSQL support them natively).

Plus cleanup:

- Replaces the polymorphic `multiplier_scopes` table with two typed pivots — `multiplier_user` and `multiplier_tier`. Eliminates the need for v2.1's Postgres-specific morph-cast workaround.
- Replaces `Multiplier::scopeTo($model)` with `scopeToUser($user)` / `scopeToTier($tier)` (plus `unscopeFromUser`, `unscopeFromTier`, `isGlobal`).
- Removes the deprecated `'level-up.table'` config key (use `'tables.experiences'`).
- Replaces the `UserForeignKey::on($table)` migration helper with a `$table->userForeignId()` Blueprint macro.
- Removes the incomplete trait method aliasing helpers added in PR #123.
- `setPoints()` now recalculates level and tier (and fires events) instead of just writing the raw column.
- `addPoints()` past the highest level threshold now caps at the top level instead of throwing.
- `addPoints()` is wrapped in `DB::transaction()` for atomicity.
- `revokeAchievement()` clears the cached relation collection so subsequent reads see the detached state.
- Adds a `challenge_completions` ledger — every completion is recorded as its own row, so the `challenges` leaderboard metric counts repeatable completions correctly; a migration backfills existing completions.
- Fixes a Postgres rollback bug on `alter_experience_audits_type_to_string`.

PHP and Laravel version requirements are unchanged (PHP 8.3+, Laravel 12 or 13).

## Step 1: Pre-flight checks

1. Confirm the user has backed up their database. The `multiplier_scopes` table will be transformed by a backfill migration — if anything goes wrong, they need a restore path.
2. Confirm the user is on v2.x and not v1.x. If `composer show cjmellor/level-up | grep versions` shows v1.x, redirect to `level-up-upgrade-v2` first.
3. Check the database version if the app uses the leaderboard: ranks need window functions — SQLite 3.25+ or MySQL 8+ (MariaDB 10.2+ and PostgreSQL are fine). MySQL 5.7 cannot run v3 leaderboard queries.
4. Run `php artisan migrate:status` to ensure no pending migrations.
5. Run `php artisan test` to ensure the test suite passes before starting.

## Step 2: Update the composer constraint

Update `composer.json`:

```json
"cjmellor/level-up": "^3.0"
```

Then run `composer update cjmellor/level-up`.

## Step 3: Update application code that uses `Multiplier::scopeTo()`

Search the codebase for `scopeTo(`. For each call, change it to `scopeToUser` or `scopeToTier` depending on the model type:

```php
// Before
$multiplier->scopeTo($user);
$multiplier->scopeTo($goldTier);
$multiplier->scopeTo($user, $goldTier);

// After
$multiplier->scopeToUser($user);
$multiplier->scopeToTier($goldTier);
$multiplier->scopeToUser($user)->scopeToTier($goldTier);
```

If a single `scopeTo` call mixes types — `scopeTo($user, $goldTier)` — split it into one call per type.

If the application uses `$multiplier->scopes()` (the old `HasMany` to `MultiplierScope`), that relation is gone. Use `$multiplier->users` and `$multiplier->tiers` (now `BelongsToMany`) instead, or `$multiplier->isGlobal()` for the "no scopes" check.

## Step 4: Update any direct references to `MultiplierScope`

Search for `MultiplierScope`. The model is removed in v3. Any code instantiating, querying, or type-hinting it needs to migrate to the typed pivot tables (`DB::table('multiplier_user')` / `DB::table('multiplier_tier')` for raw queries).

## Step 5: Update `level-up.table` config (if present)

Open the user's published `config/level-up.php`. If it has:

```php
'table' => 'experiences',
```

…delete that line and add or update the `tables` block:

```php
'tables' => [
    'experiences' => 'experiences', // or whatever value 'table' was set to
    // ... other table overrides
],
```

## Step 6: Update `level-up.tables.multiplier_scopes` config (if present)

If the published config has `'multiplier_scopes' => '...'` under `'tables'`, remove that key and add the two new ones:

```php
'tables' => [
    // ...
    'multiplier_user' => 'multiplier_user',
    'multiplier_tier' => 'multiplier_tier',
    // ...
],
```

If you had a custom name for `multiplier_scopes` (e.g. `'gamification_scopes'`), pick appropriate names for the two new tables (e.g. `'gamification_user'` and `'gamification_tier'`).

## Step 7: Update any custom migrations using `UserForeignKey::on()`

Search the application's migrations for `UserForeignKey`. For each:

```php
// Before
use LevelUp\Experience\Support\UserForeignKey;
// ...
UserForeignKey::on($table)->constrained(config('level-up.user.users_table'))->cascadeOnDelete();

// After
$table->userForeignId()->constrained(config('level-up.user.users_table'))->cascadeOnDelete();
```

Delete the `use LevelUp\Experience\Support\UserForeignKey;` import — the class is gone.

## Step 8: Audit `setPoints()` usage

Search for `setPoints(`. Confirm with the user that the new behavior (recompute level + tier, fire events) is desired at each callsite. If any callsite needs the v2.x raw-write semantics, replace with:

```php
$user->experience->update(['experience_points' => $amount]);
```

## Step 9: Audit `addPoints()` exception handlers

Search for `catch.*Points exceed` or any try/catch around `addPoints` that handles the "Points exceed the last level's experience points" exception. The exception is no longer thrown in v3 — the user is capped at the highest level instead. Remove the catch block, or convert it to a level-cap check.

## Step 10: Trait method collisions

If the host's User model defines any of `challenges()`, `streaks()`, `experience()`, `experienceHistory()`, those methods now collide with the package's trait methods (PR #123's aliasing helpers were removed). The user needs to either:

- Rename their User method (e.g. `userChallenges()`).
- Move the level-up traits onto a separate `UserProfile` / `Gamification` model and compose it.

## Step 11: Update leaderboard consumers

Search for `Leaderboard::`. Every terminal call (`generate()`, `around()`) now returns `LeaderboardEntry` objects instead of `User` models — update code that iterated users directly:

```php
// Before (v2.x)
foreach (Leaderboard::generate() as $user) {
    $user->name;
    $user->experience->experience_points;
}

// After (v3.0)
foreach (Leaderboard::generate() as $entry) {
    $entry->user->name;  // the User model (experience still eager-loaded)
    $entry->score;       // the user's score on the board's metric
    $entry->rank;        // competition rank — tied scores share a rank (1, 1, 3)
}
```

Blade templates, API resources, and tests that consumed the old shape need the same treatment.

## Step 12: Decide on the audit default

`level-up.audit.enabled` now defaults to `true`. Ask the user:

- If they want time-windowed leaderboards (`period()` / `since()` on XP), auditing must stay on — and note that windowed boards only see activity recorded after auditing was enabled.
- If they relied on the old default to keep `experience_audits` empty, set `AUDIT_POINTS=false` in `.env` — but periodic XP boards will then throw `MetricRequiresAuditingException`.
- If their published config pins `'enabled' => env('AUDIT_POINTS', false)`, nothing changes until they update it.

## Step 13: Add the new model bindings (if the config is published)

The new league, snapshot, and challenge-completion features resolve their model classes straight from `level-up.models.*`, and Laravel merges a published config **shallowly** — so if the user published `config/level-up.php` in v1/v2, their `models` array overrides the package default and the new bindings are missing (they resolve to `null` and the feature throws). `tables` are safe (they fall back to built-in defaults) and the `leaderboard` settings block is a new top-level key that merges in automatically — it is specifically the `models` map that needs the additions.

If `config/level-up.php` is **not** published, skip this step. If it **is**, add the new keys to the `models` array (or re-publish with `--force` after backing up customisations):

```php
'multiplier_user' => LevelUp\Experience\Models\Pivots\MultiplierUser::class,
'multiplier_tier' => LevelUp\Experience\Models\Pivots\MultiplierTier::class,
'challenge_completion' => LevelUp\Experience\Models\ChallengeCompletion::class,
'leaderboard_snapshot' => LevelUp\Experience\Models\LeaderboardSnapshot::class,
'division' => LevelUp\Experience\Models\Division::class,
'cohort' => LevelUp\Experience\Models\Cohort::class,
'cohort_user' => LevelUp\Experience\Models\Pivots\CohortUser::class,
```

The old `models.multiplier_scope` key (pointing at the removed `MultiplierScope`) is dead — drop it. To declare Boards or a league, also copy the new `leaderboard` block from the package's `config/level-up.php`.

## Step 14: Publish and run migrations

```bash
php artisan vendor:publish --tag="level-up-migrations"
php artisan migrate
```

Publishing picks up the new v3 tables: `leaderboard_snapshots`, `divisions`, `cohorts`, `cohort_user`, and `challenge_completions`. The `migrate_multiplier_scopes_to_typed_pivots` migration runs automatically, and `create_challenge_completions_table` backfills the new ledger with one row per already-completed challenge (so the `challenges` metric counts prior completions). If the user has existing `multiplier_scopes` data, it'll print a summary like:

```
  level-up: migrated 47 multiplier scope rows (32 user, 15 tier) into typed pivot tables; dropped multiplier_scopes.
```

If the migration encounters rows with a `scopeable_type` it doesn't recognise (not the configured user model and not the configured Tier class), it logs a warning and skips them — those rows are lost. Surface this to the user if it happens.

## Step 15: Run tests

```bash
php artisan test
```

If anything fails, walk back through the steps above — most v3 breakage is straightforward find-and-replace.

## Step 16: Verify

Spot-check that scoped multipliers still apply correctly:

```php
$user = User::find(1);
$multiplier = Multiplier::active()->forUser($user)->first();
dump($multiplier?->name);
```

Done. The user is on v3.

When to use this skill

Use this skill when a user needs to upgrade from cjmellor/level-up v2.x to v3.0.

If the user is on v1.x, run the level-up-upgrade-v2 skill first, then come back here.

What's in v3

The headline is a metric-driven leaderboard: rank by any metric (xp, level, streak, achievements, challenges, or a custom RankingMetric), time Periods, rankOf()/around(), restrictTo() for friends boards, named Boards declared in config, Snapshots with rank-change events, a leaderboard_rank challenge condition, and Leagues (a Division ladder with Cohorts, promotion, and relegation). Three leaderboard breaking changes ride along:

  • Leaderboard::generate() returns LeaderboardEntry objects ($entry->user, $entry->score, $entry->rank) instead of a bare collection of User models.
  • level-up.audit.enabled defaults to true (was false) — periodic leaderboards source their scores from the experience_audits ledger.
  • Leaderboard ranks are computed with SQL window functions, requiring SQLite 3.25+ or MySQL 8+ (MariaDB 10.2+ and PostgreSQL support them natively).

Plus cleanup:

  • Replaces the polymorphic multiplier_scopes table with two typed pivots — multiplier_user and multiplier_tier. Eliminates the need for v2.1's Postgres-specific morph-cast workaround.
  • Replaces Multiplier::scopeTo($model) with scopeToUser($user) / scopeToTier($tier) (plus unscopeFromUser, unscopeFromTier, isGlobal).
  • Removes the deprecated 'level-up.table' config key (use 'tables.experiences').
  • Replaces the UserForeignKey::on($table) migration helper with a $table->userForeignId() Blueprint macro.
  • Removes the incomplete trait method aliasing helpers added in PR #123.
  • setPoints() now recalculates level and tier (and fires events) instead of just writing the raw column.
  • addPoints() past the highest level threshold now caps at the top level instead of throwing.
  • addPoints() is wrapped in DB::transaction() for atomicity.
  • revokeAchievement() clears the cached relation collection so subsequent reads see the detached state.
  • Adds a challenge_completions ledger — every completion is recorded as its own row, so the challenges leaderboard metric counts repeatable completions correctly; a migration backfills existing completions.
  • Fixes a Postgres rollback bug on alter_experience_audits_type_to_string.

PHP and Laravel version requirements are unchanged (PHP 8.3+, Laravel 12 or 13).

Step 1: Pre-flight checks

  1. Confirm the user has backed up their database. The multiplier_scopes table will be transformed by a backfill migration — if anything goes wrong, they need a restore path.
  2. Confirm the user is on v2.x and not v1.x. If composer show cjmellor/level-up | grep versions shows v1.x, redirect to level-up-upgrade-v2 first.
  3. Check the database version if the app uses the leaderboard: ranks need window functions — SQLite 3.25+ or MySQL 8+ (MariaDB 10.2+ and PostgreSQL are fine). MySQL 5.7 cannot run v3 leaderboard queries.
  4. Run php artisan migrate:status to ensure no pending migrations.
  5. Run php artisan test to ensure the test suite passes before starting.

Step 2: Update the composer constraint

Update composer.json:

"cjmellor/level-up": "^3.0"

Then run composer update cjmellor/level-up.

Step 3: Update application code that uses Multiplier::scopeTo()

Search the codebase for scopeTo(. For each call, change it to scopeToUser or scopeToTier depending on the model type:

// Before
$multiplier->scopeTo($user);
$multiplier->scopeTo($goldTier);
$multiplier->scopeTo($user, $goldTier);

// After
$multiplier->scopeToUser($user);
$multiplier->scopeToTier($goldTier);
$multiplier->scopeToUser($user)->scopeToTier($goldTier);

If a single scopeTo call mixes types — scopeTo($user, $goldTier) — split it into one call per type.

If the application uses $multiplier->scopes() (the old HasMany to MultiplierScope), that relation is gone. Use $multiplier->users and $multiplier->tiers (now BelongsToMany) instead, or $multiplier->isGlobal() for the "no scopes" check.

Step 4: Update any direct references to MultiplierScope

Search for MultiplierScope. The model is removed in v3. Any code instantiating, querying, or type-hinting it needs to migrate to the typed pivot tables (DB::table('multiplier_user') / DB::table('multiplier_tier') for raw queries).

Step 5: Update level-up.table config (if present)

Open the user's published config/level-up.php. If it has:

'table' => 'experiences',

…delete that line and add or update the tables block:

'tables' => [
    'experiences' => 'experiences', // or whatever value 'table' was set to
    // ... other table overrides
],

Step 6: Update level-up.tables.multiplier_scopes config (if present)

If the published config has 'multiplier_scopes' => '...' under 'tables', remove that key and add the two new ones:

'tables' => [
    // ...
    'multiplier_user' => 'multiplier_user',
    'multiplier_tier' => 'multiplier_tier',
    // ...
],

If you had a custom name for multiplier_scopes (e.g. 'gamification_scopes'), pick appropriate names for the two new tables (e.g. 'gamification_user' and 'gamification_tier').

Step 7: Update any custom migrations using UserForeignKey::on()

Search the application's migrations for UserForeignKey. For each:

// Before
use LevelUp\Experience\Support\UserForeignKey;
// ...
UserForeignKey::on($table)->constrained(config('level-up.user.users_table'))->cascadeOnDelete();

// After
$table->userForeignId()->constrained(config('level-up.user.users_table'))->cascadeOnDelete();

Delete the use LevelUp\Experience\Support\UserForeignKey; import — the class is gone.

Step 8: Audit setPoints() usage

Search for setPoints(. Confirm with the user that the new behavior (recompute level + tier, fire events) is desired at each callsite. If any callsite needs the v2.x raw-write semantics, replace with:

$user->experience->update(['experience_points' => $amount]);

Step 9: Audit addPoints() exception handlers

Search for catch.*Points exceed or any try/catch around addPoints that handles the "Points exceed the last level's experience points" exception. The exception is no longer thrown in v3 — the user is capped at the highest level instead. Remove the catch block, or convert it to a level-cap check.

Step 10: Trait method collisions

If the host's User model defines any of challenges(), streaks(), experience(), experienceHistory(), those methods now collide with the package's trait methods (PR #123's aliasing helpers were removed). The user needs to either:

  • Rename their User method (e.g. userChallenges()).
  • Move the level-up traits onto a separate UserProfile / Gamification model and compose it.

Step 11: Update leaderboard consumers

Search for Leaderboard::. Every terminal call (generate(), around()) now returns LeaderboardEntry objects instead of User models — update code that iterated users directly:

// Before (v2.x)
foreach (Leaderboard::generate() as $user) {
    $user->name;
    $user->experience->experience_points;
}

// After (v3.0)
foreach (Leaderboard::generate() as $entry) {
    $entry->user->name;  // the User model (experience still eager-loaded)
    $entry->score;       // the user's score on the board's metric
    $entry->rank;        // competition rank — tied scores share a rank (1, 1, 3)
}

Blade templates, API resources, and tests that consumed the old shape need the same treatment.

Step 12: Decide on the audit default

level-up.audit.enabled now defaults to true. Ask the user:

  • If they want time-windowed leaderboards (period() / since() on XP), auditing must stay on — and note that windowed boards only see activity recorded after auditing was enabled.
  • If they relied on the old default to keep experience_audits empty, set AUDIT_POINTS=false in .env — but periodic XP boards will then throw MetricRequiresAuditingException.
  • If their published config pins 'enabled' => env('AUDIT_POINTS', false), nothing changes until they update it.

Step 13: Add the new model bindings (if the config is published)

The new league, snapshot, and challenge-completion features resolve their model classes straight from level-up.models.*, and Laravel merges a published config shallowly — so if the user published config/level-up.php in v1/v2, their models array overrides the package default and the new bindings are missing (they resolve to null and the feature throws). tables are safe (they fall back to built-in defaults) and the leaderboard settings block is a new top-level key that merges in automatically — it is specifically the models map that needs the additions.

If config/level-up.php is not published, skip this step. If it is, add the new keys to the models array (or re-publish with --force after backing up customisations):

'multiplier_user' => LevelUp\Experience\Models\Pivots\MultiplierUser::class,
'multiplier_tier' => LevelUp\Experience\Models\Pivots\MultiplierTier::class,
'challenge_completion' => LevelUp\Experience\Models\ChallengeCompletion::class,
'leaderboard_snapshot' => LevelUp\Experience\Models\LeaderboardSnapshot::class,
'division' => LevelUp\Experience\Models\Division::class,
'cohort' => LevelUp\Experience\Models\Cohort::class,
'cohort_user' => LevelUp\Experience\Models\Pivots\CohortUser::class,

The old models.multiplier_scope key (pointing at the removed MultiplierScope) is dead — drop it. To declare Boards or a league, also copy the new leaderboard block from the package's config/level-up.php.

Step 14: Publish and run migrations

php artisan vendor:publish --tag="level-up-migrations"
php artisan migrate

Publishing picks up the new v3 tables: leaderboard_snapshots, divisions, cohorts, cohort_user, and challenge_completions. The migrate_multiplier_scopes_to_typed_pivots migration runs automatically, and create_challenge_completions_table backfills the new ledger with one row per already-completed challenge (so the challenges metric counts prior completions). If the user has existing multiplier_scopes data, it'll print a summary like:

  level-up: migrated 47 multiplier scope rows (32 user, 15 tier) into typed pivot tables; dropped multiplier_scopes.

If the migration encounters rows with a scopeable_type it doesn't recognise (not the configured user model and not the configured Tier class), it logs a warning and skips them — those rows are lost. Surface this to the user if it happens.

Step 15: Run tests

php artisan test

If anything fails, walk back through the steps above — most v3 breakage is straightforward find-and-replace.

Step 16: Verify

Spot-check that scoped multipliers still apply correctly:

$user = User::find(1);
$multiplier = Multiplier::active()->forUser($user)->first();
dump($multiplier?->name);

Done. The user is on v3.

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.
abbasudo/artisan-route
mirvan/ippanel
ignibyte/aic-framework
glavweb/data-schema-bundle
glavweb/datagrid-bundle
tacticmedia/scim-server
uni-method/frame
neika-ai/sdk
adachsoft/php-code-style
pharaonic/laravel-notification-dependencies
beautinow/translug
dev-brainiac/bot-cloaked
octoberrain/html
octoberrain/extension
octoberrain/exception
octoberrain/contracts
adachsoft/api-version-bundle
cgv/localized-resource
noith/settings
kda/laravel-package