Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 14 additions & 1 deletion .github/workflows/run-tests.yml
Original file line number Diff line number Diff line change
Expand Up @@ -15,13 +15,26 @@ jobs:
php: [8.3, 8.4, 8.5]
laravel: ['12.*', '13.*']
stability: [prefer-stable]
key_type: [bigint]
include:
- php: 8.3
laravel: '12.*'
stability: prefer-lowest
key_type: bigint
- php: 8.3
laravel: '12.*'
stability: prefer-stable
key_type: uuid
- php: 8.3
laravel: '12.*'
stability: prefer-stable
key_type: ulid

name: >
PHP: v${{ matrix.php }} [${{ matrix.stability }}]
PHP: v${{ matrix.php }} [${{ matrix.stability }}] (id: ${{ matrix.key_type }})

env:
LEVELUP_TEST_KEY_TYPE: ${{ matrix.key_type }}

steps:
- name: Checkout
Expand Down
57 changes: 57 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,19 @@ return [
'model' => App\Models\User::class,
],

/*
|--------------------------------------------------------------------------
| Package Entities
|--------------------------------------------------------------------------
|
| Set 'id_type' to 'uuid' or 'ulid' if you want package IDs to be opaque.
| Only affects fresh installs — see "Customizing Identifiers" below.
|
*/
'entities' => [
'id_type' => 'bigint',
],

/*
|--------------------------------------------------------------------------
| Experience Table
Expand Down Expand Up @@ -959,6 +972,50 @@ Challenges are enabled by default. To disable:
CHALLENGES_ENABLED=false
```

# Customizing Identifiers

By default, the package's tables use auto-incrementing `bigint` primary keys. Set `level-up.entities.id_type` to `uuid` or `ulid` if you want package IDs to be opaque — useful when exposing Experience or Achievement records on a public API without leaking row counts.

```php
'entities' => [
'id_type' => 'uuid', // 'bigint' (default) | 'uuid' | 'ulid'
],
```

This applies to every package primary key (experiences, levels, achievements, streaks, tiers, multipliers, challenges, and the pivot tables) and every internal foreign key between them. Two columns are intentionally unaffected:

- `user_id` columns — these match whatever your `users` table uses, since they belong to the host application.
- `multiplier_scopes.scopeable_id` — always a string column, because it polymorphically stores keys from both the host's User table and the package's Tier table.

> [!IMPORTANT]
> This setting is for **fresh installs**. Existing installs cannot be flipped automatically — column types are baked in at migration time. The accordion below contains an AI prompt that generates the conversion migrations for your specific schema.

<details>
<summary>AI prompt: convert an existing install to <code>uuid</code> or <code>ulid</code></summary>

Paste this into your AI assistant. Replace `<TARGET>` with `uuid` or `ulid` and `<DB>` with `postgres`, `mysql`, or `sqlite`.

```text
I'm switching cjmellor/level-up's `entities.id_type` from `bigint` to `<TARGET>` on an existing `<DB>` install.

Generate a Laravel migration (or sequence of migrations) that:

1. For every level-up package table (experiences, levels, achievements, achievement_user, streak_activities, streaks, streak_histories, tiers, multipliers, multiplier_scopes, challenges, challenge_user, experience_audits): add a new `<TARGET>` column called `id_new`, generate a unique value for every existing row, then later drop the old `id` and rename `id_new` to `id`. For `multiplier_scopes`, this applies only to the `id` primary key — do NOT touch `scopeable_id` (see Constraints below).

2. For every internal foreign key column (`level_id`, `activity_id`, `achievement_id`, `challenge_id`, `tier_id`, `multiplier_id`, etc.): add a corresponding `_new` column, populate it by joining on the parent table's new ids, then drop the old column and rename.

3. Re-establish primary key and foreign key constraints in the correct order. Wrap in a transaction if `<DB>` supports DDL transactions.

Constraints:
- Do NOT touch `multiplier_scopes.scopeable_id` — that column is intentionally a string and stores morph keys from both User (host-driven) and Tier (package-driven) targets.
- Do NOT change `user_id` columns in any package table — those follow the host's `users` table type.
- After the migration runs, update `level-up.entities.id_type` in `config/level-up.php` to `<TARGET>`.
```

Review the generated migration carefully against your schema and data volume before running it on production.

</details>

# Testing

```
Expand Down
6 changes: 6 additions & 0 deletions UPGRADE.md
Original file line number Diff line number Diff line change
Expand Up @@ -178,6 +178,12 @@ class User extends Model

**To disable challenges entirely**, set `CHALLENGES_ENABLED=false` in your `.env` file.

### New: Configurable Entity ID Type

**Likelihood of Impact: Low**

The package's own primary keys can now be configured as `uuid` or `ulid` via `level-up.entities.id_type` (default remains `bigint`). Existing installs are unaffected on `composer update` — the setting only changes how *new* migrations build their tables. See the [Customizing Identifiers section in the README](README.md#customizing-identifiers) for the conversion path if you want to migrate an existing install.

### Bug Fixes Included

- `levelUp()` now correctly fires `UserLevelledUp` events for all intermediate levels (previously only fired for the final level)
Expand Down
19 changes: 19 additions & 0 deletions config/level-up.php
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,25 @@
'users_table' => 'users',
],

/*
|--------------------------------------------------------------------------
| Package Entities
|--------------------------------------------------------------------------
|
| 'id_type' controls the primary key column type used for the package's
| own tables (experiences, levels, achievements, etc.) and every internal
| foreign key between them. Set to 'uuid' or 'ulid' if you want package
| IDs to be opaque (e.g., for safe exposure on a public API surface).
| Leave as 'bigint' for standard auto-increment IDs. Only affects fresh
| migrations; existing installs keep whichever column type they already
| migrated with. See the README "Customizing Identifiers" section for
| guidance on switching an existing install.
|
*/
'entities' => [
'id_type' => 'bigint',
],

/*
|--------------------------------------------------------------------------
| Experience Table
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ return new class extends Migration {
public function up(): void
{
Schema::table(config('level-up.user.users_table'), function (Blueprint $table) {
$table->foreignId('level_id')
$table->entityForeignId('level_id')
->after('remember_token')
->nullable()
->constrained();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ return new class extends Migration {
public function up(): void
{
Schema::table('achievements', function (Blueprint $table) {
$table->foreignId('tier_id')->nullable()->constrained('tiers')->nullOnDelete();
$table->entityForeignId('tier_id')->nullable()->constrained('tiers')->nullOnDelete();
});
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ return new class extends Migration {
public function up(): void
{
Schema::table(config('level-up.table'), function (Blueprint $table) {
$table->foreignId('tier_id')->nullable()->constrained('tiers')->nullOnDelete();
$table->entityForeignId('tier_id')->nullable()->constrained('tiers')->nullOnDelete();
});
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,9 +8,9 @@ return new class extends Migration {
public function up(): void
{
Schema::create('achievement_user', function (Blueprint $table) {
$table->id();
$table->entityId();
$table->foreignId(column: config('level-up.user.foreign_key'))->constrained(config('level-up.user.users_table'));
$table->foreignId(column: 'achievement_id')->constrained();
$table->entityForeignId(column: 'achievement_id')->constrained();
$table->integer(column: 'progress')->nullable()->index();
$table->timestamps();
});
Expand Down
2 changes: 1 addition & 1 deletion database/migrations/create_achievements_table.php.stub
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ return new class extends Migration {
public function up(): void
{
Schema::create('achievements', function (Blueprint $table) {
$table->id();
$table->entityId();
$table->string('name');
$table->boolean('is_secret')->default(false);
$table->text('description')->nullable();
Expand Down
4 changes: 2 additions & 2 deletions database/migrations/create_challenge_user_table.php.stub
Original file line number Diff line number Diff line change
Expand Up @@ -8,9 +8,9 @@ return new class extends Migration {
public function up(): void
{
Schema::create('challenge_user', function (Blueprint $table) {
$table->id();
$table->entityId();
$table->foreignId(column: config('level-up.user.foreign_key'))->constrained(config('level-up.user.users_table'));
$table->foreignId(column: 'challenge_id')->constrained();
$table->entityForeignId(column: 'challenge_id')->constrained();
$table->json(column: 'progress')->nullable();
$table->timestamp(column: 'completed_at')->nullable();
$table->timestamps();
Expand Down
2 changes: 1 addition & 1 deletion database/migrations/create_challenges_table.php.stub
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ return new class extends Migration {
public function up(): void
{
Schema::create('challenges', function (Blueprint $table) {
$table->id();
$table->entityId();
$table->string(column: 'name');
$table->text(column: 'description')->nullable();
$table->string(column: 'image')->nullable();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ return new class extends Migration {
public function up(): void
{
Schema::create('experience_audits', function (Blueprint $table) {
$table->id();
$table->entityId();
$table->foreignId(config('level-up.user.foreign_key'))->constrained(config('level-up.user.users_table'));
$table->integer('points')->index();
$table->boolean('levelled_up')->default(false);
Expand Down
4 changes: 2 additions & 2 deletions database/migrations/create_experiences_table.php.stub
Original file line number Diff line number Diff line change
Expand Up @@ -9,9 +9,9 @@ return new class extends Migration
public function up()
{
Schema::create(config('level-up.table'), function (Blueprint $table) {
$table->id();
$table->entityId();
$table->foreignId(config('level-up.user.foreign_key'))->constrained(config('level-up.user.users_table'));
$table->foreignId('level_id')->constrained();
$table->entityForeignId('level_id')->constrained();
$table->integer('experience_points')->default(0)->index();
$table->timestamps();
});
Expand Down
2 changes: 1 addition & 1 deletion database/migrations/create_levels_table.php.stub
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ return new class extends Migration
public function up()
{
Schema::create('levels', function (Blueprint $table) {
$table->id();
$table->entityId();
$table->integer('level')->unique();
$table->integer('next_level_experience')->nullable()->index();
$table->timestamps();
Expand Down
6 changes: 3 additions & 3 deletions database/migrations/create_multiplier_scopes_table.php.stub
Original file line number Diff line number Diff line change
Expand Up @@ -8,10 +8,10 @@ return new class extends Migration {
public function up(): void
{
Schema::create('multiplier_scopes', function (Blueprint $table) {
$table->id();
$table->foreignId('multiplier_id')->constrained()->cascadeOnDelete();
$table->entityId();
$table->entityForeignId('multiplier_id')->constrained()->cascadeOnDelete();
$table->string('scopeable_type');
$table->unsignedBigInteger('scopeable_id');
$table->string('scopeable_id');
$table->timestamps();

$table->unique(['multiplier_id', 'scopeable_type', 'scopeable_id'], 'multiplier_scopes_unique');
Expand Down
2 changes: 1 addition & 1 deletion database/migrations/create_multipliers_table.php.stub
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ return new class extends Migration {
public function up(): void
{
Schema::create('multipliers', function (Blueprint $table) {
$table->id();
$table->entityId();
$table->string('name');
$table->text('description')->nullable();
$table->decimal('multiplier', 8, 2);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ return new class extends Migration {
public function up(): void
{
Schema::create('streak_activities', function (Blueprint $table) {
$table->id();
$table->entityId();
$table->string('name')->unique();
$table->text('description')->nullable();
$table->timestamps();
Expand Down
4 changes: 2 additions & 2 deletions database/migrations/create_streak_histories_table.php.stub
Original file line number Diff line number Diff line change
Expand Up @@ -9,9 +9,9 @@ return new class extends Migration
public function up(): void
{
Schema::create(table: 'streak_histories', callback: function (Blueprint $table) {
$table->id();
$table->entityId();
$table->foreignId(column: config(key: 'level-up.user.foreign_key'))->constrained(table: config(key: 'level-up.user.users_table'))->cascadeOnDelete();
$table->foreignId(column: 'activity_id')->constrained(table: 'streak_activities');
$table->entityForeignId(column: 'activity_id')->constrained(table: 'streak_activities');
$table->integer(column: 'count')->default(value: 1);
$table->timestamp(column: 'started_at');
$table->timestamp(column: 'ended_at')->nullable();
Expand Down
4 changes: 2 additions & 2 deletions database/migrations/create_streaks_table.php.stub
Original file line number Diff line number Diff line change
Expand Up @@ -8,9 +8,9 @@ return new class extends Migration {
public function up(): void
{
Schema::create('streaks', function (Blueprint $table) {
$table->id();
$table->entityId();
$table->foreignId(column: config('level-up.user.foreign_key'))->constrained()->onDelete('cascade');
$table->foreignId(column: 'activity_id')->constrained('streak_activities')->onDelete('cascade');
$table->entityForeignId(column: 'activity_id')->constrained('streak_activities')->onDelete('cascade');
$table->integer(column: 'count')->default(1);
$table->timestamp(column: 'activity_at');
$table->timestamps();
Expand Down
2 changes: 1 addition & 1 deletion database/migrations/create_tiers_table.php.stub
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ return new class extends Migration {
public function up(): void
{
Schema::create('tiers', function (Blueprint $table) {
$table->id();
$table->entityId();
$table->string('name')->unique();
$table->unsignedBigInteger('experience')->unique();
$table->json('metadata')->nullable();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ return new class extends Migration {
public function down(): void
{
Schema::table(config('level-up.user.users_table'), function (Blueprint $table) {
$table->foreignId('level_id')->nullable()->constrained();
$table->entityForeignId('level_id')->nullable()->constrained();
});
}
};
16 changes: 15 additions & 1 deletion resources/boost/skills/level-up-upgrade-v2/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -252,4 +252,18 @@ Review every match and ensure the corresponding fix from Step 5 has been applied
- `TypeError` from strict types — cast parameters to correct types
- `Exception` from `incrementAchievementProgress()` — grant the achievement first
3. Re-run tests until green.
4. Inform the user: "Upgrade to v2 complete. All breaking changes have been applied."

## Step 9: Optionally switch entity ID type

v2 introduces `level-up.entities.id_type` (default `bigint`) which controls the primary-key column type for the package's own tables. The other supported values are `uuid` and `ulid`.

Ask the user:

> "Would you like to switch the package's entity IDs to `uuid` or `ulid`? This is useful if you plan to expose Experience, Achievement, etc. records on a public API and don't want sequential IDs to leak row counts. It requires a one-time data migration."

- If **No**: continue without changes. The default `bigint` is identical to v1 behaviour and needs no action.
- If **Yes**: **do not attempt the conversion in this skill.** Point the user at the [Customizing Identifiers section in the README](../../../../README.md#customizing-identifiers). That section contains an AI prompt the user can paste into a fresh assistant session to generate conversion migrations tailored to their schema and database driver. The conversion is intentionally out of scope here because it depends on data volume, downtime tolerance, and DB driver specifics that this skill has no visibility into.

## Step 10: Finish

Inform the user: "Upgrade to v2 complete. All breaking changes have been applied."
54 changes: 54 additions & 0 deletions src/Concerns/HasConfigurableIds.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
<?php

declare(strict_types=1);

namespace LevelUp\Experience\Concerns;

use Illuminate\Database\Eloquent\Concerns\HasUniqueIds;
use Illuminate\Support\Str;
use InvalidArgumentException;

trait HasConfigurableIds
{
use HasUniqueIds;

public function initializeHasConfigurableIds(): void
{
$this->usesUniqueIds = in_array($this->packageIdType(), ['uuid', 'ulid'], true);
}

public function getKeyType(): string
{
return $this->packageIdType() === 'bigint' ? 'int' : 'string';
}

public function getIncrementing(): bool
{
return $this->packageIdType() === 'bigint';
}

public function newUniqueId(): string
{
$type = $this->packageIdType();

return match ($type) {
'ulid' => strtolower((string) Str::ulid()),
'uuid' => (string) Str::uuid(),
default => throw new InvalidArgumentException(
"Unknown level-up.entities.id_type [{$type}]. Expected 'bigint', 'uuid', or 'ulid'."
),
};
}

public function uniqueIds(): array
{
return $this->packageIdType() === 'bigint'
? []
: [$this->getKeyName()];
}

protected function packageIdType(): string
{
return config('level-up.entities.id_type', 'bigint');
}
}
Loading
Loading